line-gestao-frontend/src/app/pages/geral/geral.ts

5535 lines
177 KiB
TypeScript

import {
Component,
ElementRef,
ViewChild,
Inject,
PLATFORM_ID,
OnInit,
AfterViewInit,
ChangeDetectorRef,
OnDestroy,
HostListener
} from '@angular/core';
import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
HttpClient,
HttpParams,
HttpErrorResponse
} from '@angular/common/http';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { SmartSearchInputComponent } from '../../components/smart-search-input/smart-search-input';
import { GeralModalsComponent } from '../../components/page-modals/geral-modals/geral-modals';
import { PlanAutoFillService } from '../../services/plan-autofill.service';
import { AuthService } from '../../services/auth.service';
import { TenantSyncService } from '../../services/tenant-sync.service';
import { TableExportService } from '../../services/table-export.service';
import { ImportPageTemplateService } from '../../services/import-page-template.service';
import { SolicitacoesLinhasService } from '../../services/solicitacoes-linhas.service';
import {
MveAuditService,
type ApplyMveAuditResult,
type MveAuditIssue,
type MveAuditRun,
} from '../../services/mve-audit.service';
import { DropdownCoordinatorService } from '../../services/dropdown-coordinator.service';
import { firstValueFrom, Subscription, filter } from 'rxjs';
import { environment } from '../../../environments/environment';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
import {
buildPageNumbers,
clampPage,
computePageEnd,
computePageStart,
computeTotalPages
} from '../../utils/pagination.util';
import { buildApiEndpoint } from '../../utils/api-base.util';
import {
BATCH_MASS_COLUMN_GUIDE,
type BatchMassApplyMode,
buildBatchMassExampleText,
buildBatchMassHeaderLine,
type BatchMassPreviewResult,
type BatchMassSeparatorMode,
buildBatchMassPreview,
mergeMassRows
} from './batch-mass-input.util';
import {
DEFAULT_ACCOUNT_COMPANIES,
mergeAccountCompaniesWithDefaults,
normalizeConta as normalizeContaValue,
resolveEmpresaByConta,
resolveOperadoraContext,
sameConta as sameContaValue,
} from '../../utils/account-operator.util';
type SortDir = 'asc' | 'desc';
type CreateMode = 'NEW_CLIENT' | 'NEW_LINE_IN_GROUP';
type CreateEntryMode = 'SINGLE' | 'BATCH';
type AdditionalMode = 'ALL' | 'WITH' | 'WITHOUT';
type OperadoraFilterMode = 'ALL' | 'VIVO' | 'CLARO' | 'TIM';
type AdditionalServiceKey = 'gvd' | 'skeelo' | 'news' | 'travel' | 'sync' | 'dispositivo';
type BlockedStatusMode = 'ALL' | 'PERDA_ROUBO' | 'BLOQUEIO_120' | 'PRE_ATIVACAO';
type BlockedStatusFilterValue = '' | BlockedStatusMode;
type SkilFilterMode = 'ALL' | 'PF' | 'PJ' | 'RESERVA' | 'ESTOQUE';
interface LineRow {
id: string;
item: string;
linha: string;
chip?: string;
cliente: string;
usuario: string;
centroDeCustos?: string;
setorNome?: string;
aparelhoNome?: string;
aparelhoCor?: string;
status: string;
skil: string;
contrato: string;
}
interface ApiPagedResult<T> {
page: number;
pageSize: number;
total: number;
items: T[];
}
interface ApiLineList {
id: string;
item: number;
conta?: string | null;
contaEmpresa?: string | null;
empresaConta?: string | null;
linha: string | null;
chip?: string | null;
cliente: string | null;
usuario: string | null;
centroDeCustos?: string | null;
setorNome?: string | null;
aparelhoNome?: string | null;
aparelhoCor?: string | null;
vencConta: string | null;
status?: string | null;
skil?: string | null;
gestaoVozDados?: number | null;
skeelo?: number | null;
vivoNewsPlus?: number | null;
vivoTravelMundo?: number | null;
vivoSync?: number | null;
vivoGestaoDispositivo?: number | null;
}
interface SmartSearchTargetResolution {
client: string;
skilFilter: SkilFilterMode;
statusFilter: 'ALL' | 'ACTIVE' | 'BLOCKED';
blockedStatusMode: BlockedStatusMode;
requiresFilterAdjustment: boolean;
}
interface ApiLineDetail {
id: string;
item: number;
qtdLinhas?: number | null;
conta?: string | null;
linha?: string | null;
chip?: string | null;
tipoDeChip?: string | null;
cliente?: string | null;
usuario?: string | null;
centroDeCustos?: string | null;
setorId?: string | null;
setorNome?: string | null;
aparelhoId?: string | null;
aparelhoNome?: string | null;
aparelhoCor?: string | null;
aparelhoImei?: string | null;
aparelhoNotaFiscalTemArquivo?: boolean;
aparelhoReciboTemArquivo?: boolean;
planoContrato?: string | null;
status?: string | null;
skil?: string | null;
modalidade?: string | null;
dataBloqueio?: string | null;
cedente?: string | null;
solicitante?: string | null;
dataEntregaOpera?: string | null;
dataEntregaCliente?: string | null;
dtEfetivacaoServico?: string | null;
dtTerminoFidelizacao?: string | null;
vencConta?: string | null;
franquiaVivo?: number | null;
valorPlanoVivo?: number | null;
gestaoVozDados?: number | null;
skeelo?: number | null;
vivoNewsPlus?: number | null;
vivoTravelMundo?: number | null;
vivoGestaoDispositivo?: number | null;
vivoSync?: number | null;
valorContratoVivo?: number | null;
franquiaLine?: number | null;
franquiaGestao?: number | null;
locacaoAp?: number | null;
valorContratoLine?: number | null;
desconto?: number | null;
lucro?: number | null;
createdAt?: string | null;
updatedAt?: string | null;
}
type UpdateMobileLineRequest = Omit<ApiLineDetail, 'id'>;
type CreateMobileLineRequest = Omit<ApiLineDetail, 'id'> & {
reservaLineId?: string | null;
};
interface ClientGroupDto {
cliente: string;
totalLinhas: number;
ativos: number;
bloqueados: number;
}
interface AccountCompanyOption {
empresa: string;
contas: string[];
}
interface ReservaLineOption {
value: string;
label: string;
linha: string;
chip?: string;
usuario?: string;
}
interface CreateBatchLineDraft extends Partial<CreateMobileLineRequest> {
uid: number;
linha: string;
chip: string;
tipoDeChip: string;
usuario: string;
contaEmpresa?: string;
}
interface BatchLineValidation {
uid: number;
index: number;
linhaDigits: string;
errors: string[];
}
interface BatchValidationSummary {
total: number;
valid: number;
invalid: number;
duplicates: number;
}
interface CreateMobileLinesBatchRequest {
lines: CreateMobileLineRequest[];
}
interface CreateMobileLinesBatchResponse {
created?: number;
items?: Array<{
id: string;
item: number;
linha?: string | null;
cliente?: string | null;
}>;
}
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[];
}
type BatchStatusAction = 'BLOCK' | 'UNBLOCK';
interface BatchLineStatusUpdateRequestDto {
action: 'block' | 'unblock';
blockStatus?: string | null;
applyToAllFiltered: boolean;
lineIds: string[];
search?: string | null;
skil?: string | null;
clients?: string[];
additionalMode?: string | null;
additionalServices?: string | null;
usuario?: string | null;
}
interface BatchLineStatusUpdateItemResultDto {
id: string;
item?: number;
linha?: string | null;
usuario?: string | null;
statusAnterior?: string | null;
statusNovo?: string | null;
success: boolean;
message: string;
}
interface BatchLineStatusUpdateResultDto {
requested: number;
updated: number;
failed: number;
items: BatchLineStatusUpdateItemResultDto[];
}
type MveAuditFilterMode =
| 'ALL'
| 'STATUS'
| 'DATA'
| 'ONLY_IN_SYSTEM'
| 'ONLY_IN_REPORT'
| 'DUPLICATES'
| 'INVALID'
| 'UNKNOWN';
type MveAuditApplyMode = 'ALL_SYNCABLE' | 'FILTERED_SYNCABLE';
interface MveApplySelectionSummary {
totalIssues: number;
totalStatusIssues: number;
totalDataIssues: number;
totalAffectedLines: number;
}
@Component({
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent, SmartSearchInputComponent, GeralModalsComponent],
templateUrl: './geral.html',
styleUrls: ['./geral.scss']
})
export class Geral implements OnInit, AfterViewInit, OnDestroy {
private static nextFilterDropdownScopeId = 0;
readonly vm = this;
readonly batchMassColumnGuide = BATCH_MASS_COLUMN_GUIDE;
toastMessage = '';
private readonly filterDropdownScopeId = Geral.nextFilterDropdownScopeId++;
private readonly clientDropdownId = `geral-client-filter-${this.filterDropdownScopeId}`;
private readonly additionalDropdownId = `geral-additional-filter-${this.filterDropdownScopeId}`;
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
@ViewChild('excelInput') excelInput!: ElementRef<HTMLInputElement>;
@ViewChild('batchExcelInput') batchExcelInput?: ElementRef<HTMLInputElement>;
@ViewChild('editModal', { static: false }) editModal!: ElementRef<HTMLElement>;
@ViewChild('createModal', { static: false }) createModal!: ElementRef<HTMLElement>;
@ViewChild('detailModal', { static: false }) detailModal!: ElementRef<HTMLElement>;
@ViewChild('financeModal', { static: false }) financeModal!: ElementRef<HTMLElement>;
constructor(
@Inject(PLATFORM_ID) private platformId: object,
private http: HttpClient,
private cdr: ChangeDetectorRef,
private planAutoFill: PlanAutoFillService,
private authService: AuthService,
private router: Router,
private route: ActivatedRoute,
private tenantSyncService: TenantSyncService,
private solicitacoesLinhasService: SolicitacoesLinhasService,
private tableExportService: TableExportService,
private importPageTemplateService: ImportPageTemplateService,
private mveAuditService: MveAuditService,
private dropdownCoordinator: DropdownCoordinatorService
) {}
private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'lines');
private readonly templatesApiBase = buildApiEndpoint(environment.apiUrl, 'templates');
loading = false;
exporting = false;
exportingTemplate = false;
isSysAdmin = false;
isGestor = false;
isFinanceiro = false;
isClientRestricted = false;
rows: LineRow[] = [];
clientGroups: ClientGroupDto[] = [];
groupLines: LineRow[] = [];
expandedGroup: string | null = null;
loadingLines = false;
searchTerm = '';
groupSearchTerm = '';
filterSkil: SkilFilterMode = 'ALL';
filterStatus: 'ALL' | 'ACTIVE' | 'BLOCKED' = 'ALL';
blockedStatusMode: BlockedStatusMode = 'ALL';
additionalMode: AdditionalMode = 'ALL';
selectedAdditionalServices: AdditionalServiceKey[] = [];
filterOperadora: OperadoraFilterMode = 'ALL';
filterContaEmpresa = '';
readonly blockedStatusFilterOptions: Array<{ label: string; value: BlockedStatusFilterValue }> = [
{ label: 'Todos os bloqueios', value: 'ALL' },
{ label: 'Bloqueio Perda/Roubo', value: 'PERDA_ROUBO' },
{ label: 'Bloqueio por 120 dias', value: 'BLOQUEIO_120' },
{ label: 'Bloqueio de Pré Ativação', value: 'PRE_ATIVACAO' },
];
readonly additionalServiceOptions: Array<{ key: AdditionalServiceKey; label: string }> = [
{ key: 'gvd', label: 'Gestão Voz e Dados' },
{ key: 'skeelo', label: 'Skeelo' },
{ key: 'news', label: 'Vivo News Plus' },
{ key: 'travel', label: 'Vivo Travel Mundo' },
{ key: 'sync', label: 'Vivo Sync' },
{ key: 'dispositivo', label: 'Vivo Gestão Dispositivo' }
];
readonly operadoraFilterOptions: Array<{ label: string; value: OperadoraFilterMode }> = [
{ label: 'Todas operadoras', value: 'ALL' },
{ label: 'VIVO', value: 'VIVO' },
{ label: 'CLARO', value: 'CLARO' },
{ label: 'TIM', value: 'TIM' },
];
clientsList: string[] = [];
loadingClientsList = false;
selectedClients: string[] = [];
showClientMenu = false;
showAdditionalMenu = false;
clientSearchTerm = '';
viewMode: 'GROUPS' | 'TABLE' = 'GROUPS';
sortKey: keyof LineRow = 'item';
sortDir: SortDir = 'asc';
page = 1;
pageSize = 10;
pageSizeOptions = [10, 20, 50, 100];
total = 0;
detailOpen = false;
financeOpen = false;
editOpen = false;
editSaving = false;
requestSaving = false;
createOpen = false;
createSaving = false;
createMode: CreateMode = 'NEW_CLIENT';
createEntryMode: CreateEntryMode = 'SINGLE';
createBatchLines: CreateBatchLineDraft[] = [];
selectedBatchLineUid: number | null = null;
batchDetailOpen = false;
batchMassInputText = '';
batchMassSeparatorMode: BatchMassSeparatorMode = 'AUTO';
batchMassPreview: BatchMassPreviewResult | null = null;
batchExcelPreview: BatchExcelPreviewResultDto | null = null;
batchExcelPreviewLoading = false;
batchExcelTemplateDownloading = false;
batchExcelPreviewApplyMode: BatchMassApplyMode = 'ADD';
createBatchValidationByUid: Record<number, BatchLineValidation> = {};
createBatchValidationSummary: BatchValidationSummary = { total: 0, valid: 0, invalid: 0, duplicates: 0 };
reservaSelectedLineIds: string[] = [];
reservaTransferOpen = false;
reservaTransferSaving = false;
moveToReservaOpen = false;
moveToReservaSaving = false;
reservaTransferClients: string[] = [];
reservaTransferModel: { clienteDestino: string; usuarioDestino: string; skilDestino: string } = {
clienteDestino: '',
usuarioDestino: '',
skilDestino: ''
};
reservaTransferLastResult: AssignReservaLinesResultDto | null = null;
moveToReservaLastResult: AssignReservaLinesResultDto | null = null;
batchStatusOpen = false;
batchStatusSaving = false;
batchStatusAction: BatchStatusAction = 'BLOCK';
batchStatusType = '';
batchStatusUsuario = '';
batchStatusLastResult: BatchLineStatusUpdateResultDto | null = null;
mveAuditOpen = false;
mveAuditProcessing = false;
mveAuditApplying = false;
mveAuditFile: File | null = null;
mveAuditResult: MveAuditRun | null = null;
mveAuditError = '';
mveAuditFilter: MveAuditFilterMode = 'ALL';
mveAuditSearchTerm = '';
mveAuditPage = 1;
mveAuditPageSize = 10;
mveAuditPageSizeOptions = [10, 25, 50, 100];
mveAuditApplyConfirmOpen = false;
mveAuditApplyMode: MveAuditApplyMode = 'ALL_SYNCABLE';
mveAuditApplyLastResult: ApplyMveAuditResult | null = null;
detailData: any = null;
financeData: any = null;
editModel: any = null;
aparelhoNotaFiscalFile: File | null = null;
aparelhoReciboFile: File | null = null;
private editingId: string | null = null;
private searchTimer: any = null;
private groupSearchTimer: any = null;
private navigationSub?: Subscription;
private dropdownSyncSub?: Subscription;
private keepPageOnNextGroupsLoad = false;
private searchResolvedClient: string | null = null;
private searchRequestVersion = 0;
private kpiRequestVersion = 0;
private groupsRequestVersion = 0;
private linesRequestVersion = 0;
private clientsRequestVersion = 0;
private createBatchUidSeed = 0;
loadingKpis = false;
kpiTotalClientes = 0;
kpiTotalLinhas = 0;
kpiAtivas = 0;
kpiBloqueadas = 0;
readonly statusOptions = ['ATIVO', 'BLOQUEIO PERDA/ROUBO', 'BLOQUEIO 120 DIAS', 'BLOQUEIO DE PRÉ ATIVAÇÃO'];
readonly skilOptions = ['PESSOA FÍSICA', 'PESSOA JURÍDICA', 'RESERVA'];
planOptions = [
'SMART EMPRESAS 0.2GB TE',
'SMART EMPRESAS 0.5GB TE',
'SMART EMPRESAS 2GB D',
'SMART EMPRESAS 4GB',
'SMART EMPRESAS 6GB',
'SMART EMPRESAS 8GB',
'SMART EMPRESAS 10GB',
'SMART EMPRESAS 20GB',
'SMART EMPRESAS 50GB',
'M2M 20MB',
'M2M 50MB'
];
private readonly fallbackAccountCompanies: AccountCompanyOption[] = DEFAULT_ACCOUNT_COMPANIES.map((group) => ({
empresa: group.empresa,
contas: [...group.contas],
}));
accountCompanies: AccountCompanyOption[] = [...this.fallbackAccountCompanies];
loadingAccountCompanies = false;
createReservaLineOptions: ReservaLineOption[] = [];
loadingCreateReservaLines = false;
private createReservaLineLookup = new Map<string, ReservaLineOption>();
get contaEmpresaOptions(): string[] {
return this.accountCompanies.map((x) => x.empresa);
}
get contaEmpresaFilterOptions(): Array<{ label: string; value: string }> {
const empresas = this.getContaEmpresaOptionsByOperadora(this.filterOperadora);
const merged = this.mergeOption(this.filterContaEmpresa, empresas);
return [
{ label: 'Todas empresas', value: '' },
...merged.map((empresa) => ({ label: empresa, value: empresa })),
];
}
get contaOptionsForCreate(): string[] {
return this.getContasByEmpresa(this.createModel?.contaEmpresa);
}
get contaEmpresaOptionsForEdit(): string[] {
return this.mergeOption(this.editModel?.contaEmpresa, this.contaEmpresaOptions);
}
get contaOptionsForEdit(): string[] {
const empresaSelecionada = (this.editModel?.contaEmpresa ?? '').toString().trim();
const baseOptions = empresaSelecionada
? this.getContasByEmpresa(empresaSelecionada)
: this.getAllContas();
return this.mergeOption(this.editModel?.conta, baseOptions);
}
get planOptionsForEdit(): string[] {
return this.mergeOption(this.editModel?.planoContrato, this.planOptions);
}
get statusOptionsForEdit(): string[] {
return this.mergeOption(this.editModel?.status, this.statusOptions);
}
get skilOptionsForEdit(): string[] {
return this.mergeOption(this.editModel?.skil, this.skilOptions);
}
createModel: any = {
cliente: '',
docType: 'PF',
docNumber: '',
contaEmpresa: '',
reservaLineId: '',
linha: '',
chip: '',
tipoDeChip: '',
usuario: '',
status: '',
planoContrato: '',
conta: '',
vencConta: '',
skil: 'PESSOA FÍSICA',
modalidade: '',
item: 0,
cedente: '',
solicitante: '',
dataBloqueio: '',
dataEntregaOpera: '',
dataEntregaCliente: '',
dtEfetivacaoServico: '',
dtTerminoFidelizacao: '',
franquiaVivo: null,
valorPlanoVivo: null,
gestaoVozDados: null,
skeelo: null,
vivoNewsPlus: null,
vivoTravelMundo: null,
vivoGestaoDispositivo: null,
vivoSync: null,
valorContratoVivo: null,
franquiaLine: null,
franquiaGestao: null,
locacaoAp: null,
valorContratoLine: null,
desconto: null,
lucro: null
};
get isCreateBatchMode(): boolean {
return this.createEntryMode === 'BATCH';
}
get createBatchCount(): number {
return this.createBatchLines.length;
}
get createSubmitText(): string {
if (this.createSaving) return '';
if (this.isCreateBatchMode) {
const count = this.createBatchCount;
return count > 0 ? `Cadastrar Lote (${count})` : 'Cadastrar Lote';
}
return 'Cadastrar';
}
get isCreateSaveDisabled(): boolean {
if (this.createSaving) return true;
if (!this.isCreateBatchMode) return false;
if (this.createBatchCount === 0) return true;
return this.createBatchValidationSummary.invalid > 0;
}
get hasBatchSelection(): boolean {
if (this.selectedBatchLineUid == null) return false;
return this.createBatchLines.some((x) => x.uid === this.selectedBatchLineUid);
}
get selectedBatchLine(): CreateBatchLineDraft | null {
if (this.selectedBatchLineUid == null) return null;
return this.createBatchLines.find((x) => x.uid === this.selectedBatchLineUid) ?? null;
}
get selectedBatchLineIndex(): number {
if (this.selectedBatchLineUid == null) return -1;
return this.createBatchLines.findIndex((x) => x.uid === this.selectedBatchLineUid);
}
get batchActiveDetailLine(): CreateBatchLineDraft | null {
if (!this.batchDetailOpen) return null;
return this.selectedBatchLine;
}
get batchValidationMessage(): string {
const s = this.createBatchValidationSummary;
if (!this.isCreateBatchMode) return '';
if (s.total === 0) return 'Adicione linhas ao lote para começar o preenchimento.';
if (s.invalid > 0) return `Corrija ${s.invalid} linha(s) inválida(s) antes de salvar.`;
return `Lote pronto para envio: ${s.valid} linha(s) válida(s).`;
}
get batchMassHasPreview(): boolean {
return !!this.batchMassPreview && (this.batchMassPreview.recognizedRows > 0 || this.batchMassPreview.parseErrors.length > 0);
}
get batchMassSeparatorLabel(): string {
if (!this.batchMassPreview) return '';
if (this.batchMassPreview.separator === 'TAB') return 'TAB';
if (this.batchMassPreview.separator === 'PIPE') return '|';
return ';';
}
get batchMassPreviewRowsPreview(): Array<{ line: number; data: Record<string, string>; errors: string[] }> {
const rows = this.batchMassPreview?.rows ?? [];
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);
}
get blockedStatusSelectValue(): BlockedStatusFilterValue {
return this.filterStatus === 'BLOCKED' ? this.blockedStatusMode : '';
}
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.isReserveContextFilter() && !!(this.expandedGroup ?? '').trim();
}
get isExpandedGroupNamedReserva(): boolean {
const group = (this.expandedGroup ?? '').toString().trim();
return group.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0
|| group.localeCompare('ESTOQUE', 'pt-BR', { sensitivity: 'base' }) === 0;
}
get hasGroupLineSelectionTools(): boolean {
return this.canManageLines && !!(this.expandedGroup ?? '').trim();
}
get canManageLines(): boolean {
return this.isSysAdmin || this.isGestor;
}
get canMoveSelectedLinesToReserva(): boolean {
return this.hasGroupLineSelectionTools && !this.isReservaExpandedGroup && !this.isExpandedGroupNamedReserva;
}
get blockedStatusOptions(): string[] {
return this.statusOptions.filter((status) => !this.isActiveStatus(status));
}
get batchStatusSelectionCount(): number {
return this.reservaSelectedCount;
}
get canOpenBatchStatusModal(): boolean {
if (!this.canManageLines) return false;
if (this.loading || this.batchStatusSaving) return false;
return this.batchStatusSelectionCount > 0;
}
get canSubmitBatchStatusModal(): boolean {
if (this.batchStatusSaving) return false;
if (this.batchStatusSelectionCount <= 0) return false;
if (this.batchStatusAction === 'BLOCK' && !String(this.batchStatusType ?? '').trim()) return false;
return true;
}
get batchStatusActionLabel(): string {
return this.batchStatusAction === 'BLOCK' ? 'Bloquear' : 'Desbloquear';
}
get batchStatusTargetDescription(): string {
return `${this.batchStatusSelectionCount} linha(s) selecionada(s)`;
}
get batchStatusUserOptions(): string[] {
const users = (this.groupLines ?? [])
.map((x) => (x.usuario ?? '').toString().trim())
.filter((x) => !!x);
const current = (this.batchStatusUsuario ?? '').toString().trim();
if (current) users.push(current);
return Array.from(new Set(users)).sort((a, b) => a.localeCompare(b, 'pt-BR', { sensitivity: 'base' }));
}
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;
if (v.localeCompare('ESTOQUE', '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 &&
current.localeCompare('ESTOQUE', 'pt-BR', { sensitivity: 'base' }) !== 0
) {
set.add(current);
}
return Array.from(set).sort((a, b) => a.localeCompare(b, 'pt-BR', { sensitivity: 'base' }));
}
get isGroupMode(): boolean {
return this.viewMode === 'GROUPS';
}
get isKpiLoading(): boolean {
return this.loading || this.loadingKpis;
}
get hasAdditionalFiltersApplied(): boolean {
return this.additionalMode !== 'ALL' || this.selectedAdditionalServices.length > 0;
}
get hasOperadoraEmpresaFiltersApplied(): boolean {
return this.filterOperadora !== 'ALL' || !!this.filterContaEmpresa.trim();
}
get hasClientSideFiltersApplied(): boolean {
return this.hasAdditionalFiltersApplied || this.filterStatus !== 'ALL' || this.hasOperadoraEmpresaFiltersApplied;
}
get additionalModeLabel(): string {
if (this.additionalMode === 'WITH') return 'Com adicionais';
if (this.additionalMode === 'WITHOUT') return 'Sem adicionais';
return 'Todos os adicionais';
}
get additionalSelectedLabels(): string[] {
return this.selectedAdditionalServices
.map((key) => this.additionalServiceOptions.find((x) => x.key === key)?.label ?? key);
}
get hasMveAuditResult(): boolean {
return !!this.mveAuditResult;
}
get filteredMveAuditIssues(): MveAuditIssue[] {
const source = this.mveAuditResult?.issues ?? [];
const search = this.normalizeMveSearchTerm(this.mveAuditSearchTerm);
return source.filter((issue) => {
if (!this.matchesMveIssueFilter(issue)) {
return false;
}
if (!search) {
return true;
}
const haystack = [
issue.numeroLinha,
issue.issueType,
issue.situation,
issue.systemStatus,
issue.reportStatus,
issue.systemPlan,
issue.reportPlan,
issue.actionSuggestion,
issue.notes,
...(issue.differences ?? []).flatMap((diff) => [diff.label, diff.systemValue, diff.reportValue]),
]
.map((value) => this.normalizeMveSearchTerm(value))
.join(' ');
return haystack.includes(search);
});
}
get mveAuditTotalPages(): number {
return computeTotalPages(this.filteredMveAuditIssues.length, this.mveAuditPageSize);
}
get mveAuditPageNumbers(): number[] {
return buildPageNumbers(this.mveAuditPage, this.mveAuditTotalPages);
}
get pagedMveAuditIssues(): MveAuditIssue[] {
const start = computePageStart(this.filteredMveAuditIssues.length, this.mveAuditPage, this.mveAuditPageSize);
if (start <= 0) {
return this.filteredMveAuditIssues.slice(0, this.mveAuditPageSize);
}
const offset = start - 1;
return this.filteredMveAuditIssues.slice(offset, offset + this.mveAuditPageSize);
}
get mveAuditPageStart(): number {
return computePageStart(this.filteredMveAuditIssues.length, this.mveAuditPage, this.mveAuditPageSize);
}
get mveAuditPageEnd(): number {
return computePageEnd(this.filteredMveAuditIssues.length, this.mveAuditPage, this.mveAuditPageSize);
}
get allSyncableMveIssues(): MveAuditIssue[] {
return (this.mveAuditResult?.issues ?? []).filter((issue) => issue.syncable && !issue.applied);
}
get filteredSyncableMveIssues(): MveAuditIssue[] {
return this.filteredMveAuditIssues.filter((issue) => issue.syncable && !issue.applied);
}
get selectedMveApplyIssues(): MveAuditIssue[] {
return this.mveAuditApplyMode === 'FILTERED_SYNCABLE'
? this.filteredSyncableMveIssues
: this.allSyncableMveIssues;
}
get mveApplySelectionSummary(): MveApplySelectionSummary {
const selected = this.selectedMveApplyIssues;
const affectedLines = new Set<string>();
let totalStatusIssues = 0;
let totalDataIssues = 0;
for (const issue of selected) {
if (issue.mobileLineId) affectedLines.add(issue.mobileLineId);
else if (issue.numeroLinha) affectedLines.add(issue.numeroLinha);
if (this.issueHasStatusDifference(issue)) totalStatusIssues++;
if (this.issueHasDataDifference(issue)) totalDataIssues++;
}
return {
totalIssues: selected.length,
totalStatusIssues,
totalDataIssues,
totalAffectedLines: affectedLines.size,
};
}
get canSubmitMveAudit(): boolean {
return !!this.mveAuditFile && !this.mveAuditProcessing && !this.mveAuditApplying;
}
get canOpenMveApplyConfirm(): boolean {
return !this.mveAuditApplying && this.mveApplySelectionSummary.totalIssues > 0;
}
// ✅ fecha dropdown ao clicar fora
@HostListener('document:click', ['$event'])
onDocumentClick(ev: MouseEvent) {
if (!isPlatformBrowser(this.platformId)) return;
// Se modal estiver aberto, não mexe no dropdown por clique no overlay
if (this.anyModalOpen()) return;
if (!this.showClientMenu && !this.showAdditionalMenu) return;
const target = ev.target as HTMLElement | null;
if (!target) return;
const insideClient = !!target.closest('.client-filter-wrap');
const insideAdditional = !!target.closest('.additional-filter-wrap');
let changed = false;
if (this.showClientMenu && !insideClient) {
this.closeClientDropdown();
changed = true;
}
if (this.showAdditionalMenu && !insideAdditional) {
this.closeAdditionalDropdown();
changed = true;
}
if (changed) {
this.cdr.detectChanges();
}
}
// ✅ ESC fecha dropdown OU modal (sem conflito)
@HostListener('document:keydown', ['$event'])
onDocumentKeydown(ev: Event) {
if (!isPlatformBrowser(this.platformId)) return;
const keyboard = ev as KeyboardEvent;
if (keyboard.key === 'Escape') {
if (this.anyModalOpen()) {
keyboard.preventDefault();
keyboard.stopPropagation();
this.closeAllModals();
return;
}
let changed = false;
if (this.showClientMenu) {
this.closeClientDropdown();
changed = true;
}
if (this.showAdditionalMenu) {
this.closeAdditionalDropdown();
changed = true;
}
if (changed) {
keyboard.stopPropagation();
this.cdr.detectChanges();
}
}
}
ngOnDestroy(): void {
if (this.searchTimer) clearTimeout(this.searchTimer);
if (this.groupSearchTimer) clearTimeout(this.groupSearchTimer);
this.navigationSub?.unsubscribe();
this.dropdownSyncSub?.unsubscribe();
}
ngOnInit(): void {
if (!isPlatformBrowser(this.platformId)) return;
this.isSysAdmin = this.authService.hasRole('sysadmin');
this.isGestor = this.authService.hasRole('gestor');
this.isFinanceiro = this.authService.hasRole('financeiro');
this.isClientRestricted = !(this.isSysAdmin || this.isGestor || this.isFinanceiro);
if (this.isClientRestricted) {
this.filterSkil = 'ALL';
this.filterStatus = 'ALL';
this.blockedStatusMode = 'ALL';
this.additionalMode = 'ALL';
this.selectedAdditionalServices = [];
this.selectedClients = [];
}
this.dropdownSyncSub = this.dropdownCoordinator.activeDropdownId$.subscribe((activeId) => {
if (activeId !== this.clientDropdownId) {
this.closeClientDropdown(false);
}
if (activeId !== this.additionalDropdownId) {
this.closeAdditionalDropdown(false);
}
});
}
async ngAfterViewInit() {
if (!isPlatformBrowser(this.platformId)) return;
this.initAnimations();
setTimeout(() => {
this.applyRouteFilters(this.route.snapshot.queryParams);
this.refreshData();
if (!this.isClientRestricted) {
this.loadClients();
}
this.loadPlanRules();
this.loadAccountCompanies();
const state = history.state;
if (state && state.toastMessage) {
const msg = String(state.toastMessage);
const newState = { ...state };
delete newState.toastMessage;
history.replaceState(newState, '', location.href);
this.showToast(msg);
}
});
this.navigationSub = this.router.events
.pipe(filter((event): event is NavigationEnd => event instanceof NavigationEnd))
.subscribe((event) => {
const urlAfterRedirects = event.urlAfterRedirects || '';
const url = urlAfterRedirects.toLowerCase();
if (!url.includes('/geral')) return;
const parsed = this.router.parseUrl(urlAfterRedirects);
this.applyRouteFilters(parsed.queryParams ?? {});
this.searchResolvedClient = null;
if (!this.isClientRestricted) {
this.loadClients();
}
this.refreshData();
});
}
private initAnimations() {
document.documentElement.classList.add('js-animate');
setTimeout(() => {
const items = document.querySelectorAll<HTMLElement>('[data-animate]');
items.forEach((el) => el.classList.add('is-visible'));
}, 100);
}
private applyRouteFilters(query: Record<string, unknown>): void {
const skil = this.parseQuerySkilFilter(query['skil']);
const reservaMode = this.parseQueryReservaMode(query['reservaMode']);
const resolvedSkil = skil === 'RESERVA' && reservaMode === 'stock' ? 'ESTOQUE' : skil;
if (resolvedSkil && (!this.isClientRestricted || resolvedSkil === 'ALL')) {
this.filterSkil = resolvedSkil;
}
const status = this.parseQueryStatusFilter(query['statusMode'] ?? query['statusFilter']);
if (status) {
this.filterStatus = status;
}
if (this.filterStatus !== 'BLOCKED') {
this.blockedStatusMode = 'ALL';
}
const blockedMode = this.parseQueryBlockedStatusMode(query['blockedMode'] ?? query['blockedType'] ?? query['statusSubtype']);
if (blockedMode) {
this.blockedStatusMode = blockedMode;
this.filterStatus = 'BLOCKED';
}
if (!this.isClientRestricted) {
const additionalMode = this.parseQueryAdditionalMode(query['additionalMode']);
if (additionalMode) {
this.additionalMode = additionalMode;
}
const additionalServices = this.parseQueryAdditionalServices(query['additionalServices']);
if (additionalServices) {
this.selectedAdditionalServices = additionalServices;
}
}
this.expandedGroup = null;
this.groupLines = [];
this.selectedClients = [];
this.clientSearchTerm = '';
this.searchTerm = '';
this.searchResolvedClient = null;
this.page = 1;
}
private parseQuerySkilFilter(value: unknown): SkilFilterMode | null {
const token = this.normalizeFilterToken(value);
if (!token) return null;
if (token === 'ALL' || token === 'TODOS') return 'ALL';
if (token === 'PF' || token === 'PESSOAFISICA') return 'PF';
if (token === 'PJ' || token === 'PESSOAJURIDICA') return 'PJ';
if (token === 'RESERVA' || token === 'RESERVAS') return 'RESERVA';
if (token === 'ESTOQUE' || token === 'STOCK') return 'ESTOQUE';
return null;
}
private parseQueryReservaMode(value: unknown): 'assigned' | 'stock' | 'all' | null {
const token = this.normalizeFilterToken(value);
if (!token) return null;
if (token === 'ASSIGNED' || token === 'RESERVA' || token === 'RESERVAS') return 'assigned';
if (token === 'STOCK' || token === 'ESTOQUE') return 'stock';
if (token === 'ALL' || token === 'TODOS') return 'all';
return null;
}
private parseQueryStatusFilter(value: unknown): 'ALL' | 'ACTIVE' | 'BLOCKED' | null {
const token = this.normalizeFilterToken(value);
if (!token) return null;
if (token === 'ALL' || token === 'TODOS') return 'ALL';
if (
token === 'ACTIVE' ||
token === 'ATIVAS' ||
token === 'ATIVOS' ||
token === 'ATIVA' ||
token === 'ATIVO'
) {
return 'ACTIVE';
}
if (
token === 'BLOCKED' ||
token === 'BLOQUEADAS' ||
token === 'BLOQUEADOS' ||
token === 'BLOQUEADA' ||
token === 'BLOQUEADO' ||
token === 'BLOQUEIO'
) {
return 'BLOCKED';
}
return null;
}
private parseQueryBlockedStatusMode(value: unknown): BlockedStatusMode | null {
const token = this.normalizeFilterToken(value);
if (!token) return null;
if (token === 'ALL' || token === 'TODOS') return 'ALL';
if (
token === 'PERDAROUBO' ||
token === 'PERDAEROUBO' ||
token === 'PERDA' ||
token === 'ROUBO'
) {
return 'PERDA_ROUBO';
}
if (
token === '120' ||
token === '120DIAS' ||
token === 'BLOQUEIO120' ||
token === 'BLOQUEIO120DIAS'
) {
return 'BLOQUEIO_120';
}
if (
token === 'PREATIVACAO' ||
token === 'PREATIV' ||
token === 'BLOQUEIOPREATIVACAO'
) {
return 'PRE_ATIVACAO';
}
return null;
}
private parseQueryAdditionalMode(value: unknown): AdditionalMode | null {
const token = this.normalizeFilterToken(value);
if (!token) return null;
if (token === 'ALL' || token === 'TODOS') return 'ALL';
if (token === 'WITH' || token === 'COM') return 'WITH';
if (token === 'WITHOUT' || token === 'SEM') return 'WITHOUT';
return null;
}
private parseQueryAdditionalServices(value: unknown): AdditionalServiceKey[] | null {
if (value === undefined || value === null) return null;
const asString = Array.isArray(value) ? value.join(',') : String(value ?? '');
const chunks = asString
.split(',')
.map((part) => this.mapAdditionalServiceToken(part))
.filter((part): part is AdditionalServiceKey => !!part);
const unique = Array.from(new Set(chunks));
return unique;
}
private mapAdditionalServiceToken(value: unknown): AdditionalServiceKey | null {
const token = this.normalizeFilterToken(value);
if (!token) return null;
if (token === 'GVD' || token === 'GESTAOVOZDADOS' || token === 'GESTAOVOZEDADOS') return 'gvd';
if (token === 'SKEELO') return 'skeelo';
if (token === 'NEWS' || token === 'VIVONEWS' || token === 'VIVONEWSPLUS') return 'news';
if (token === 'TRAVEL' || token === 'TRAVELMUNDO' || token === 'VIVOTRAVELMUNDO') return 'travel';
if (token === 'SYNC' || token === 'VIVOSYNC') return 'sync';
if (token === 'DISPOSITIVO' || token === 'GESTAODISPOSITIVO' || token === 'VIVOGESTAODISPOSITIVO') return 'dispositivo';
return null;
}
private normalizeFilterToken(value: unknown): string {
return String(value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^A-Za-z0-9]/g, '')
.toUpperCase()
.trim();
}
private isReserveContextFilter(filter: SkilFilterMode = this.filterSkil): boolean {
return filter === 'RESERVA' || filter === 'ESTOQUE';
}
private isStockFilter(filter: SkilFilterMode = this.filterSkil): boolean {
return filter === 'ESTOQUE';
}
private isStockClientName(value: unknown): boolean {
return (value ?? '').toString().trim().localeCompare('ESTOQUE', 'pt-BR', { sensitivity: 'base' }) === 0;
}
private getClientFallbackLabel(emptyFallback = '', filter: SkilFilterMode = this.filterSkil): string {
if (filter === 'ESTOQUE') return 'ESTOQUE';
if (filter === 'RESERVA') return 'RESERVA';
return emptyFallback;
}
private getReservaModeForApi(filter: SkilFilterMode = this.filterSkil): 'assigned' | 'stock' | null {
if (filter === 'ESTOQUE') return 'stock';
if (filter === 'RESERVA') return 'assigned';
return null;
}
private async loadPlanRules() {
try {
await this.planAutoFill.load();
const extraPlans = this.planAutoFill.getPlanOptions();
if (extraPlans.length > 0) {
this.planOptions = this.mergeOptionList(this.planOptions, extraPlans);
this.cdr.detectChanges();
}
} catch {
// silencioso: segue com a lista estática
}
}
private loadAccountCompanies() {
this.loadingAccountCompanies = true;
this.http.get<AccountCompanyOption[]>(`${this.apiBase}/account-companies`).subscribe({
next: (data) => {
const normalized = this.normalizeAccountCompanies(data);
const source = normalized.length > 0 ? normalized : this.fallbackAccountCompanies;
this.accountCompanies = mergeAccountCompaniesWithDefaults(source);
this.loadingAccountCompanies = false;
this.syncContaEmpresaFilterByOperadora();
this.syncContaEmpresaSelection(this.createModel);
this.syncContaEmpresaSelection(this.editModel);
this.syncContaEmpresaSelection(this.detailData);
this.syncContaEmpresaSelection(this.financeData);
this.cdr.detectChanges();
},
error: () => {
this.accountCompanies = mergeAccountCompaniesWithDefaults(this.fallbackAccountCompanies);
this.loadingAccountCompanies = false;
this.syncContaEmpresaFilterByOperadora();
this.syncContaEmpresaSelection(this.createModel);
this.syncContaEmpresaSelection(this.editModel);
this.syncContaEmpresaSelection(this.detailData);
this.syncContaEmpresaSelection(this.financeData);
}
});
}
// ============================================================
// ✅ FIX PRINCIPAL: limpeza forçada de backdrops/scroll lock
// ============================================================
private anyModalOpen(): boolean {
return !!(
this.detailOpen ||
this.financeOpen ||
this.editOpen ||
this.createOpen ||
this.reservaTransferOpen ||
this.moveToReservaOpen ||
this.batchStatusOpen ||
this.mveAuditOpen
);
}
private cleanupModalArtifacts() {
if (!isPlatformBrowser(this.platformId)) return;
// Remove qualquer backdrop “preso” (Bootstrap ou o seu custom)
document
.querySelectorAll('.modal-backdrop, .modal-backdrop-custom')
.forEach((el) => el.remove());
// Remove lock de scroll que o Bootstrap às vezes aplica
document.body.classList.remove('modal-open');
document.body.style.removeProperty('overflow');
document.body.style.removeProperty('padding-right');
// Às vezes também cai no html
document.documentElement.style.removeProperty('overflow');
}
closeAllModals() {
// Fecha tudo de forma imediata (sem await) para o overlay sumir na hora
this.detailOpen = false;
this.financeOpen = false;
this.editOpen = false;
this.createOpen = false;
this.reservaTransferOpen = false;
this.moveToReservaOpen = false;
this.batchStatusOpen = false;
this.mveAuditOpen = false;
this.mveAuditApplyConfirmOpen = false;
this.detailData = null;
this.financeData = null;
this.aparelhoNotaFiscalFile = null;
this.aparelhoReciboFile = null;
this.editSaving = false;
this.requestSaving = false;
this.createSaving = false;
this.editModel = null;
this.editingId = null;
this.batchDetailOpen = false;
this.batchMassPreview = null;
this.batchExcelPreview = null;
this.batchExcelPreviewLoading = false;
this.batchExcelTemplateDownloading = false;
this.reservaTransferSaving = false;
this.moveToReservaSaving = false;
this.batchStatusSaving = false;
this.reservaTransferLastResult = null;
this.moveToReservaLastResult = null;
this.batchStatusLastResult = null;
this.batchStatusUsuario = '';
this.mveAuditFile = null;
this.mveAuditProcessing = false;
this.mveAuditApplying = false;
this.mveAuditResult = null;
this.mveAuditError = '';
this.mveAuditFilter = 'ALL';
this.mveAuditSearchTerm = '';
this.mveAuditPage = 1;
this.mveAuditApplyMode = 'ALL_SYNCABLE';
this.mveAuditApplyLastResult = null;
// Limpa overlays/locks residuais
this.cleanupModalArtifacts();
// Garante que o Angular replique no DOM imediatamente
this.cdr.detectChanges();
// Segurança extra: um “tick” depois, limpa de novo (caso algo tenha sido inserido depois)
setTimeout(() => this.cleanupModalArtifacts(), 0);
}
// ============================================================
private withNoCache(params: HttpParams): HttpParams {
return params.set('_ts', Date.now().toString());
}
refreshData(opts?: { keepCurrentPage?: boolean }) {
const keepCurrentPage = !!opts?.keepCurrentPage;
this.keepPageOnNextGroupsLoad = keepCurrentPage;
if (!keepCurrentPage && (this.isReserveContextFilter() || this.filterStatus !== 'ALL')) {
this.page = 1;
}
this.searchResolvedClient = null;
this.loadKpis();
this.viewMode = 'GROUPS';
this.loadGroups();
}
private isSpecificSearchTerm(term: string): boolean {
const t = (term ?? '').trim();
if (!t) return false;
const digits = this.normalizeDigits(t);
if (!digits) return false;
if (digits.length >= 17) return true; // ICCID
if (digits.length >= 8 && digits.length <= 14) return true; // linha
return false;
}
private resetGroupSearchState(): void {
this.groupSearchTerm = '';
if (this.groupSearchTimer) {
clearTimeout(this.groupSearchTimer);
this.groupSearchTimer = null;
}
}
private getActiveExpandedGroupSearchTerm(): string | undefined {
const groupTerm = (this.groupSearchTerm ?? '').trim();
if (groupTerm) return groupTerm;
const globalTerm = (this.searchTerm ?? '').trim();
return globalTerm || undefined;
}
private normalizeDigits(value: unknown): string {
return String(value ?? '').replace(/\D/g, '');
}
private isReservaValue(value: unknown): boolean {
return this.normalizeFilterToken(value) === 'RESERVA';
}
private resolveSkilFilterFromLine(line: Pick<ApiLineList, 'skil' | 'cliente' | 'usuario'> | null | undefined): SkilFilterMode {
if (!line) return 'ALL';
if (this.isStockClientName(line.cliente)) return 'ESTOQUE';
if (this.isReservaValue(line.cliente) || this.isReservaValue(line.usuario) || this.isReservaValue(line.skil)) {
return 'RESERVA';
}
const parsed = this.parseQuerySkilFilter(line.skil);
return parsed ?? 'ALL';
}
private findBestSpecificSearchMatch(items: ApiLineList[], term: string): ApiLineList | null {
if (!Array.isArray(items) || items.length === 0) return null;
const digits = this.normalizeDigits(term);
if (!digits) return null;
const isIccidSearch = digits.length >= 17;
const exactMatches = items.filter((item) => {
const lineDigits = this.normalizeDigits(item?.linha);
const chipDigits = this.normalizeDigits(item?.chip);
return lineDigits === digits || chipDigits === digits;
});
if (exactMatches.length > 0) return exactMatches[0];
const compatibleMatches = items.filter((item) => {
const lineDigits = this.normalizeDigits(item?.linha);
const chipDigits = this.normalizeDigits(item?.chip);
if (isIccidSearch) {
return !!chipDigits && (
chipDigits.endsWith(digits) ||
digits.endsWith(chipDigits) ||
chipDigits.includes(digits)
);
}
return !!lineDigits && (
lineDigits.endsWith(digits) ||
digits.endsWith(lineDigits) ||
lineDigits.includes(digits)
);
});
return compatibleMatches[0] ?? items[0] ?? null;
}
private async findSpecificSearchMatch(
term: string,
options?: {
ignoreCurrentFilters?: boolean;
skilFilter?: SkilFilterMode;
}
): Promise<ApiLineList | null> {
const s = (term ?? '').trim();
if (!s) return null;
let params = new HttpParams()
.set('page', '1')
.set('pageSize', options?.ignoreCurrentFilters ? '200' : '500')
.set('search', s);
if (!options?.ignoreCurrentFilters) {
params = this.applyBaseFilters(params);
this.selectedClients.forEach((c) => (params = params.append('client', c)));
} else if (!options?.skilFilter) {
params = params.set('includeAssignedReservaInAll', 'true');
} else if (options?.skilFilter === 'PF') {
params = params.set('skil', 'PESSOA FÍSICA');
} else if (options?.skilFilter === 'PJ') {
params = params.set('skil', 'PESSOA JURÍDICA');
} else if (options?.skilFilter === 'ESTOQUE') {
params = params.set('skil', 'RESERVA').set('reservaMode', 'stock');
} else if (options?.skilFilter === 'RESERVA') {
params = params.set('skil', 'RESERVA');
}
try {
const response = await firstValueFrom(
this.http.get<ApiPagedResult<ApiLineList>>(this.apiBase, { params: this.withNoCache(params) })
);
let items = response?.items ?? [];
if (!options?.ignoreCurrentFilters && this.hasClientSideFiltersApplied) {
items = this.applyAdditionalFiltersClientSide(items);
}
return this.findBestSpecificSearchMatch(items, s);
} catch {
return null;
}
}
private buildSmartSearchTarget(
line: ApiLineList,
requiresFilterAdjustment: boolean
): SmartSearchTargetResolution | null {
if (!line) return null;
const skilFilter = this.resolveSkilFilterFromLine(line);
const blockedStatusMode = this.resolveBlockedStatusMode(line?.status ?? '') ?? 'ALL';
const statusFilter = blockedStatusMode !== 'ALL'
? 'BLOCKED'
: this.isActiveStatus(line?.status ?? '')
? 'ACTIVE'
: 'ALL';
const client = ((line?.cliente ?? '').toString().trim()) || this.getClientFallbackLabel('SEM CLIENTE', skilFilter);
return {
client,
skilFilter,
statusFilter,
blockedStatusMode,
requiresFilterAdjustment
};
}
private async resolveSmartSearchTarget(term: string): Promise<SmartSearchTargetResolution | null> {
const currentContextMatch = await this.findSpecificSearchMatch(term);
if (currentContextMatch) {
return this.buildSmartSearchTarget(currentContextMatch, false);
}
const globalMatch = await this.findSpecificSearchMatch(term, { ignoreCurrentFilters: true });
if (globalMatch) {
return this.buildSmartSearchTarget(globalMatch, true);
}
const reservaMatch = await this.findSpecificSearchMatch(term, {
ignoreCurrentFilters: true,
skilFilter: 'RESERVA'
});
if (reservaMatch) {
return this.buildSmartSearchTarget(reservaMatch, true);
}
return null;
}
private applySmartSearchFilters(target: SmartSearchTargetResolution): void {
this.filterSkil = target.skilFilter;
this.filterStatus = target.statusFilter;
this.blockedStatusMode = target.statusFilter === 'BLOCKED' ? target.blockedStatusMode : 'ALL';
this.selectedClients = [];
this.clientSearchTerm = '';
this.additionalMode = 'ALL';
this.selectedAdditionalServices = [];
}
onSearch() {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(async () => {
const requestVersion = ++this.searchRequestVersion;
this.resetGroupSearchState();
this.expandedGroup = null;
this.groupLines = [];
this.page = 1;
const term = (this.searchTerm ?? '').trim();
if (!term) {
if (requestVersion !== this.searchRequestVersion) return;
this.searchResolvedClient = null;
this.loadKpis();
this.loadGroups();
return;
}
if (this.isSpecificSearchTerm(term)) {
const target = await this.resolveSmartSearchTarget(term);
if (requestVersion !== this.searchRequestVersion) return;
if (target) {
if (target.requiresFilterAdjustment) {
this.applySmartSearchFilters(target);
if (!this.isClientRestricted) {
this.loadClients();
}
}
this.searchResolvedClient = target.client;
this.loadKpis();
await this.loadOnlyThisClientGroup(target.client);
if (requestVersion !== this.searchRequestVersion) return;
this.expandedGroup = target.client;
this.fetchGroupLines(target.client, term);
return;
}
}
if (requestVersion !== this.searchRequestVersion) return;
this.searchResolvedClient = null;
this.loadKpis();
this.loadGroups();
}, 300);
}
onGroupSearchChange(): void {
if (this.groupSearchTimer) clearTimeout(this.groupSearchTimer);
this.groupSearchTimer = setTimeout(() => {
if (!this.expandedGroup) return;
this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm());
}, 300);
}
private loadOnlyThisClientGroup(clientName: string): Promise<void> {
const requestVersion = ++this.groupsRequestVersion;
this.loading = true;
if (this.hasClientSideFiltersApplied) {
return this.loadOnlyThisClientGroupFromLines(clientName, requestVersion);
}
let params = new HttpParams().set('page', '1').set('pageSize', '9999');
params = this.applyBaseFilters(params);
params = params.append('client', clientName);
return new Promise((resolve) => {
this.http.get<ApiPagedResult<ClientGroupDto>>(`${this.apiBase}/groups`, { params: this.withNoCache(params) }).subscribe({
next: (res) => {
if (requestVersion !== this.groupsRequestVersion) {
resolve();
return;
}
let items = res.items || [];
items = items.filter(
(g) => (g.cliente || '').trim().toUpperCase() === (clientName || '').trim().toUpperCase()
);
this.clientGroups = this.sortGroupsWithReservaFirst(items);
this.total = items.length;
this.loading = false;
this.cdr.detectChanges();
resolve();
},
error: () => {
if (requestVersion !== this.groupsRequestVersion) {
resolve();
return;
}
this.loading = false;
this.showToast('Erro ao carregar grupos.');
resolve();
}
});
});
}
private async loadOnlyThisClientGroupFromLines(clientName: string, requestVersion: number): Promise<void> {
try {
const lines = await this.fetchLinesForGrouping();
if (requestVersion !== this.groupsRequestVersion) return;
const target = (clientName || '').trim().toUpperCase();
let groups = this.buildGroupsFromLines(lines);
groups = groups.filter((g) => (g.cliente || '').trim().toUpperCase() === target);
this.clientGroups = this.sortGroupsWithReservaFirst(groups);
this.total = groups.length;
this.loading = false;
this.cdr.detectChanges();
} catch {
if (requestVersion !== this.groupsRequestVersion) return;
this.loading = false;
this.showToast('Erro ao carregar grupos.');
}
}
private loadClients() {
const requestVersion = ++this.clientsRequestVersion;
this.loadingClientsList = true;
this.clientsList = [];
if (this.hasClientSideFiltersApplied) {
void this.loadClientsFromLines(requestVersion);
return;
}
let params = new HttpParams();
params = this.applyBaseFilters(params);
this.http.get<string[]>(`${this.apiBase}/clients`, { params: this.withNoCache(params) }).subscribe({
next: (data) => {
if (requestVersion !== this.clientsRequestVersion) return;
this.clientsList = data || [];
this.loadingClientsList = false;
},
error: () => {
if (requestVersion !== this.clientsRequestVersion) return;
this.loadingClientsList = false;
console.error('Erro ao carregar lista de clientes para o filtro.');
}
});
}
private async loadClientsFromLines(requestVersion: number): Promise<void> {
try {
let baseParams = new HttpParams()
.set('sortBy', 'cliente')
.set('sortDir', 'asc');
baseParams = this.applyBaseFilters(baseParams);
const pageSize = 5000;
let page = 1;
let expectedTotal = 0;
const allLines: ApiLineList[] = [];
while (page <= 500) {
const params = baseParams
.set('page', String(page))
.set('pageSize', String(pageSize));
const response = await firstValueFrom(
this.http.get<ApiPagedResult<ApiLineList>>(this.apiBase, { params: this.withNoCache(params) })
);
const items = response?.items ?? [];
expectedTotal = this.toInt(response?.total);
allLines.push(...items);
if (items.length === 0) break;
if (items.length < pageSize) break;
if (expectedTotal > 0 && allLines.length >= expectedTotal) break;
page += 1;
}
if (requestVersion !== this.clientsRequestVersion) return;
const filteredLines = this.applyAdditionalFiltersClientSide(allLines);
const fallbackClient = this.getClientFallbackLabel('');
const clients = filteredLines
.map((x) => ((x.cliente ?? '').toString().trim()) || fallbackClient)
.filter((x) => !!x);
this.clientsList = Array.from(new Set(clients)).sort((a, b) =>
a.localeCompare(b, 'pt-BR', { sensitivity: 'base' })
);
this.loadingClientsList = false;
} catch {
if (requestVersion !== this.clientsRequestVersion) return;
this.loadingClientsList = false;
console.error('Erro ao carregar lista de clientes para o filtro.');
}
}
setFilter(type: SkilFilterMode) {
if (this.isClientRestricted && type !== 'ALL') return;
const isSameFilter = this.filterSkil === type;
this.expandedGroup = null;
this.groupLines = [];
if (!isSameFilter) {
this.filterSkil = type;
}
this.selectedClients = [];
this.clientSearchTerm = '';
this.searchResolvedClient = null;
if (!this.isClientRestricted) {
this.loadClients();
}
this.page = 1;
this.refreshData();
}
toggleActiveFilter() {
if (this.filterStatus === 'ACTIVE') {
this.filterStatus = 'ALL';
} else {
this.filterStatus = 'ACTIVE';
this.blockedStatusMode = 'ALL';
}
this.expandedGroup = null;
this.groupLines = [];
this.searchResolvedClient = null;
this.selectedClients = [];
this.clientSearchTerm = '';
this.page = 1;
if (!this.isClientRestricted) {
this.loadClients();
}
this.refreshData();
}
setBlockedStatusFilter(mode: BlockedStatusFilterValue) {
const normalizedMode = mode ?? '';
if (normalizedMode === '') {
this.filterStatus = 'ALL';
this.blockedStatusMode = 'ALL';
this.expandedGroup = null;
this.groupLines = [];
this.searchResolvedClient = null;
this.selectedClients = [];
this.clientSearchTerm = '';
this.page = 1;
if (!this.isClientRestricted) {
this.loadClients();
}
this.refreshData();
return;
}
const sameSelection = this.filterStatus === 'BLOCKED' && this.blockedStatusMode === normalizedMode;
if (sameSelection) {
this.filterStatus = 'ALL';
this.blockedStatusMode = 'ALL';
} else {
this.filterStatus = 'BLOCKED';
this.blockedStatusMode = normalizedMode;
}
this.expandedGroup = null;
this.groupLines = [];
this.searchResolvedClient = null;
this.selectedClients = [];
this.clientSearchTerm = '';
this.page = 1;
if (!this.isClientRestricted) {
this.loadClients();
}
this.refreshData();
}
onBlockedStatusSelectChange(value: BlockedStatusFilterValue) {
this.setBlockedStatusFilter(value);
}
setAdditionalMode(mode: AdditionalMode) {
if (this.isClientRestricted) return;
if (this.additionalMode === mode) return;
this.additionalMode = mode;
this.expandedGroup = null;
this.groupLines = [];
this.searchResolvedClient = null;
this.page = 1;
this.loadClients();
this.refreshData();
}
toggleAdditionalService(key: AdditionalServiceKey) {
if (this.isClientRestricted) return;
const idx = this.selectedAdditionalServices.indexOf(key);
if (idx >= 0) this.selectedAdditionalServices.splice(idx, 1);
else this.selectedAdditionalServices.push(key);
this.expandedGroup = null;
this.groupLines = [];
this.searchResolvedClient = null;
this.page = 1;
this.loadClients();
this.refreshData();
}
isAdditionalServiceSelected(key: AdditionalServiceKey): boolean {
return this.selectedAdditionalServices.includes(key);
}
clearAdditionalFilters() {
if (this.isClientRestricted) return;
this.additionalMode = 'ALL';
this.selectedAdditionalServices = [];
this.expandedGroup = null;
this.groupLines = [];
this.searchResolvedClient = null;
this.page = 1;
this.loadClients();
this.refreshData();
}
setOperadoraFilter(mode: OperadoraFilterMode) {
if (this.isClientRestricted) return;
this.filterOperadora = mode;
this.syncContaEmpresaFilterByOperadora();
this.expandedGroup = null;
this.groupLines = [];
this.searchResolvedClient = null;
this.page = 1;
this.loadClients();
this.refreshData();
}
setContaEmpresaFilter(empresa: string) {
if (this.isClientRestricted) return;
const next = (empresa ?? '').toString().trim();
this.filterContaEmpresa = next;
this.expandedGroup = null;
this.groupLines = [];
this.searchResolvedClient = null;
this.page = 1;
this.loadClients();
this.refreshData();
}
private applyBaseFilters(params: HttpParams): HttpParams {
let next = params;
if (this.filterSkil === 'PF') next = next.set('skil', 'PESSOA FÍSICA');
else if (this.filterSkil === 'PJ') next = next.set('skil', 'PESSOA JURÍDICA');
else if (this.isReserveContextFilter()) {
next = next.set('skil', 'RESERVA');
const reservaMode = this.getReservaModeForApi();
if (reservaMode) next = next.set('reservaMode', reservaMode);
} else {
next = next.set('includeAssignedReservaInAll', 'true');
}
if (this.filterStatus === 'BLOCKED') {
next = next.set('statusMode', 'blocked');
if (this.blockedStatusMode === 'PERDA_ROUBO') next = next.set('statusSubtype', 'perda_roubo');
else if (this.blockedStatusMode === 'BLOQUEIO_120') next = next.set('statusSubtype', '120_dias');
else if (this.blockedStatusMode === 'PRE_ATIVACAO') next = next.set('statusSubtype', 'pre_ativacao');
}
if (this.additionalMode === 'WITH') next = next.set('additionalMode', 'with');
else if (this.additionalMode === 'WITHOUT') next = next.set('additionalMode', 'without');
if (this.selectedAdditionalServices.length > 0) {
next = next.set('additionalServices', this.selectedAdditionalServices.join(','));
}
return next;
}
private getAdditionalValue(line: ApiLineList, key: AdditionalServiceKey): number {
const raw = key === 'gvd'
? line.gestaoVozDados
: key === 'skeelo'
? line.skeelo
: key === 'news'
? line.vivoNewsPlus
: key === 'travel'
? line.vivoTravelMundo
: key === 'sync'
? line.vivoSync
: line.vivoGestaoDispositivo;
const n = this.toNullableNumber(raw);
return n ?? 0;
}
private hasAnyAdditional(line: ApiLineList): boolean {
return (this.getAdditionalValue(line, 'gvd') > 0) ||
(this.getAdditionalValue(line, 'skeelo') > 0) ||
(this.getAdditionalValue(line, 'news') > 0) ||
(this.getAdditionalValue(line, 'travel') > 0) ||
(this.getAdditionalValue(line, 'sync') > 0) ||
(this.getAdditionalValue(line, 'dispositivo') > 0);
}
private resolveBlockedStatusMode(status: unknown): Exclude<BlockedStatusMode, 'ALL'> | null {
const normalized = this.normalizeFilterToken(status);
if (!normalized) return null;
const hasBlockedToken =
normalized.includes('BLOQUE') ||
normalized.includes('PERDA') ||
normalized.includes('ROUBO') ||
normalized.includes('FURTO') ||
normalized.includes('PREATIV');
if (!hasBlockedToken) return null;
if (normalized.includes('120')) return 'BLOQUEIO_120';
if (normalized.includes('PREATIV')) return 'PRE_ATIVACAO';
if (normalized.includes('PERDA') || normalized.includes('ROUBO') || normalized.includes('FURTO')) {
return 'PERDA_ROUBO';
}
return 'PRE_ATIVACAO';
}
private isBlockedStatus(status: unknown): boolean {
return this.resolveBlockedStatusMode(status) !== null;
}
private isActiveStatus(status: unknown): boolean {
if (this.isBlockedStatus(status)) return false;
return this.normalizeFilterToken(status).includes('ATIVO');
}
private matchesBlockedStatusMode(status: unknown): boolean {
const mode = this.resolveBlockedStatusMode(status);
if (!mode) return false;
if (this.blockedStatusMode === 'ALL') return true;
return mode === this.blockedStatusMode;
}
private matchesAdditionalFilters(line: ApiLineList): boolean {
if (this.filterStatus === 'BLOCKED' && !this.matchesBlockedStatusMode(line?.status ?? '')) {
return false;
}
if (this.filterStatus === 'ACTIVE' && !this.isActiveStatus(line?.status ?? '')) {
return false;
}
if (!this.matchesOperadoraContaEmpresaFilters(line)) {
return false;
}
const selected = this.selectedAdditionalServices;
const hasSelected = selected.length > 0;
if (hasSelected) {
if (this.additionalMode === 'WITHOUT') {
return selected.every((svc) => this.getAdditionalValue(line, svc) <= 0);
}
// WITH e também ALL com serviços selecionados
return selected.some((svc) => this.getAdditionalValue(line, svc) > 0);
}
if (this.additionalMode === 'WITH') {
return this.hasAnyAdditional(line);
}
if (this.additionalMode === 'WITHOUT') {
return !this.hasAnyAdditional(line);
}
return true;
}
private matchesOperadoraContaEmpresaFilters(line: ApiLineList): boolean {
const hasOperadora = this.filterOperadora !== 'ALL';
const selectedEmpresa = this.filterContaEmpresa.trim();
const hasEmpresa = !!selectedEmpresa;
if (!hasOperadora && !hasEmpresa) return true;
const conta = (line as any)?.conta ?? (line as any)?.Conta ?? '';
const empresaConta = (line as any)?.contaEmpresa
?? (line as any)?.empresaConta
?? (line as any)?.ContaEmpresa
?? (line as any)?.EmpresaConta
?? (line as any)?.empresa_conta
?? (line as any)?.Empresa_Conta
?? (line as any)?.['empresa (conta)']
?? (line as any)?.['EMPRESA (CONTA)']
?? '';
const context = resolveOperadoraContext({
conta,
empresaConta,
accountCompanies: this.accountCompanies,
});
if (hasOperadora && context.operadora !== this.filterOperadora) {
return false;
}
if (!hasEmpresa) return true;
const resolvedEmpresa = (context.empresaConta || this.findEmpresaByConta(conta) || '').toString().trim();
if (!resolvedEmpresa) return false;
return this.normalizeFilterToken(resolvedEmpresa) === this.normalizeFilterToken(selectedEmpresa);
}
private applyAdditionalFiltersClientSide(lines: ApiLineList[]): ApiLineList[] {
if (!Array.isArray(lines) || lines.length === 0) return [];
return lines.filter((line) => this.matchesAdditionalFilters(line));
}
private loadKpis() {
const requestVersion = ++this.kpiRequestVersion;
this.loadingKpis = true;
this.cdr.detectChanges();
void this.loadKpisInternal(requestVersion);
}
private async loadKpisInternal(requestVersion: number) {
try {
const groups = await this.fetchAllGroupsForKpis();
if (requestVersion !== this.kpiRequestVersion) return;
if (groups.length === 0) {
await this.loadKpisFromLines(requestVersion);
return;
}
this.applyKpisFromGroups(groups);
} catch {
if (requestVersion !== this.kpiRequestVersion) return;
await this.loadKpisFromLines(requestVersion);
return;
}
if (requestVersion !== this.kpiRequestVersion) return;
this.loadingKpis = false;
this.cdr.detectChanges();
}
private async fetchAllGroupsForKpis(): Promise<ClientGroupDto[]> {
if (this.hasClientSideFiltersApplied) {
const lines = await this.fetchLinesForGrouping();
let groups = this.buildGroupsFromLines(lines);
if (this.searchResolvedClient) {
const target = (this.searchResolvedClient || '').trim().toUpperCase();
groups = groups.filter((g) => (g.cliente || '').trim().toUpperCase() === target);
}
if (this.selectedClients.length > 0) {
groups = groups.filter((g) =>
this.selectedClients.some(
(selected) => (selected || '').trim().toUpperCase() === (g.cliente || '').trim().toUpperCase()
)
);
}
return groups;
}
let baseParams = new HttpParams();
baseParams = this.applyBaseFilters(baseParams);
if (!this.searchResolvedClient && this.searchTerm) {
baseParams = baseParams.set('search', this.searchTerm);
}
const pageSize = 2000;
let page = 1;
let expectedTotal = 0;
const allGroups: ClientGroupDto[] = [];
while (page <= 500) {
const params = baseParams
.set('page', String(page))
.set('pageSize', String(pageSize));
const response = await firstValueFrom(
this.http.get<ApiPagedResult<ClientGroupDto>>(`${this.apiBase}/groups`, { params: this.withNoCache(params) })
);
const items = response?.items ?? [];
expectedTotal = this.toInt(response?.total);
allGroups.push(...items);
if (items.length === 0) break;
if (items.length < pageSize) break;
if (expectedTotal > 0 && allGroups.length >= expectedTotal) break;
page += 1;
}
if (this.searchResolvedClient) {
const target = (this.searchResolvedClient || '').trim().toUpperCase();
return allGroups.filter((g) => (g.cliente || '').trim().toUpperCase() === target);
}
if (this.selectedClients.length > 0) {
return allGroups.filter((g) =>
this.selectedClients.some(
(selected) => (selected || '').trim().toUpperCase() === (g.cliente || '').trim().toUpperCase()
)
);
}
return allGroups;
}
private async loadKpisFromLines(requestVersion: number = this.kpiRequestVersion) {
try {
const lines = await this.fetchLinesForGrouping();
let groups = this.buildGroupsFromLines(lines);
if (this.searchResolvedClient) {
const target = (this.searchResolvedClient || '').trim().toUpperCase();
groups = groups.filter((g) => (g.cliente || '').trim().toUpperCase() === target);
} else if (this.selectedClients.length > 0) {
groups = groups.filter((g) =>
this.selectedClients.some(
(selected) =>
(selected || '').trim().toUpperCase() === (g.cliente || '').trim().toUpperCase()
)
);
}
if (requestVersion !== this.kpiRequestVersion) return;
this.applyKpisFromGroups(groups);
} catch {
if (requestVersion !== this.kpiRequestVersion) return;
this.applyKpisFromGroups([]);
} finally {
if (requestVersion !== this.kpiRequestVersion) return;
this.loadingKpis = false;
this.cdr.detectChanges();
}
}
private applyKpisFromGroups(groups: ClientGroupDto[]): void {
const safe = Array.isArray(groups) ? groups : [];
this.kpiTotalClientes = safe.length;
this.kpiTotalLinhas = safe.reduce((acc, group) => acc + this.toInt(group?.totalLinhas), 0);
this.kpiAtivas = safe.reduce((acc, group) => acc + this.toInt(group?.ativos), 0);
this.kpiBloqueadas = safe.reduce((acc, group) => acc + this.toInt(group?.bloqueados), 0);
}
private loadGroups() {
const requestVersion = ++this.groupsRequestVersion;
this.loading = true;
const hasSelection = this.selectedClients.length > 0;
const hasResolved = !!this.searchResolvedClient;
const keepCurrentPage = this.keepPageOnNextGroupsLoad;
this.keepPageOnNextGroupsLoad = false;
if (!keepCurrentPage && (this.isReserveContextFilter() || this.filterStatus !== 'ALL') && !hasSelection && !hasResolved) {
this.page = 1;
}
if (this.hasClientSideFiltersApplied) {
void this.loadGroupsFromLines(hasSelection, hasResolved, requestVersion);
return;
}
const pageToLoad = (hasSelection || hasResolved) ? '1' : String(this.page);
const sizeToLoad = (hasSelection || hasResolved) ? '9999' : String(this.pageSize);
let params = new HttpParams().set('page', pageToLoad).set('pageSize', sizeToLoad);
params = this.applyBaseFilters(params);
if (!hasResolved && this.searchTerm) params = params.set('search', this.searchTerm);
if (hasResolved) {
params = params.append('client', this.searchResolvedClient!);
} else if (hasSelection) {
this.selectedClients.forEach((c) => (params = params.append('client', c)));
}
this.http.get<ApiPagedResult<ClientGroupDto>>(`${this.apiBase}/groups`, { params: this.withNoCache(params) }).subscribe({
next: (res) => {
if (requestVersion !== this.groupsRequestVersion) return;
let items = res.items || [];
if (hasResolved) {
const target = (this.searchResolvedClient || '').trim().toUpperCase();
items = items.filter((g) => (g.cliente || '').trim().toUpperCase() === target);
this.total = items.length;
} else if (hasSelection) {
items = items.filter((g) =>
this.selectedClients.some(
(selected) =>
(selected || '').trim().toUpperCase() === (g.cliente || '').trim().toUpperCase()
)
);
this.total = items.length;
} else {
this.total = res.total;
}
if (items.length === 0) {
this.loadGroupsFromLines(hasSelection, hasResolved, requestVersion);
return;
}
this.clientGroups = this.sortGroupsWithReservaFirst(items);
this.loading = false;
this.cdr.detectChanges();
},
error: () => {
if (requestVersion !== this.groupsRequestVersion) return;
this.loadGroupsFromLines(hasSelection, hasResolved, requestVersion);
}
});
}
private async loadGroupsFromLines(hasSelection: boolean, hasResolved: boolean, requestVersion: number) {
try {
const lines = await this.fetchLinesForGrouping();
if (requestVersion !== this.groupsRequestVersion) return;
let groups = this.buildGroupsFromLines(lines);
if (hasResolved) {
const target = (this.searchResolvedClient || '').trim().toUpperCase();
groups = groups.filter((g) => (g.cliente || '').trim().toUpperCase() === target);
} else if (hasSelection) {
groups = groups.filter((g) =>
this.selectedClients.some(
(selected) =>
(selected || '').trim().toUpperCase() === (g.cliente || '').trim().toUpperCase()
)
);
}
this.total = groups.length;
if (!hasSelection && !hasResolved) {
const start = Math.max(0, (this.page - 1) * this.pageSize);
groups = groups.slice(start, start + this.pageSize);
}
this.clientGroups = this.sortGroupsWithReservaFirst(groups);
} catch {
if (requestVersion !== this.groupsRequestVersion) return;
this.clientGroups = [];
this.total = 0;
this.showToast('Erro ao carregar grupos.');
} finally {
if (requestVersion !== this.groupsRequestVersion) return;
this.loading = false;
this.cdr.detectChanges();
}
}
private async fetchLinesForGrouping(): Promise<ApiLineList[]> {
let baseParams = new HttpParams()
.set('sortBy', 'cliente')
.set('sortDir', 'asc');
baseParams = this.applyBaseFilters(baseParams);
if (this.searchResolvedClient) {
baseParams = baseParams.set('client', this.searchResolvedClient);
} else if (this.searchTerm) {
baseParams = baseParams.set('search', this.searchTerm);
}
const pageSize = 5000;
let page = 1;
let expectedTotal = 0;
const allLines: ApiLineList[] = [];
while (page <= 500) {
const params = baseParams
.set('page', String(page))
.set('pageSize', String(pageSize));
const response = await firstValueFrom(
this.http.get<ApiPagedResult<ApiLineList>>(this.apiBase, { params: this.withNoCache(params) })
);
const items = response?.items ?? [];
expectedTotal = this.toInt(response?.total);
allLines.push(...items);
if (items.length === 0) break;
if (items.length < pageSize) break;
if (expectedTotal > 0 && allLines.length >= expectedTotal) break;
page += 1;
}
return this.applyAdditionalFiltersClientSide(allLines);
}
private buildGroupsFromLines(lines: ApiLineList[]): ClientGroupDto[] {
const grouped = new Map<string, ClientGroupDto>();
const fallbackClient = this.getClientFallbackLabel('SEM CLIENTE');
for (const row of lines ?? []) {
const client = ((row?.cliente ?? '').toString().trim()) || fallbackClient;
const key = client.toUpperCase();
let group = grouped.get(key);
if (!group) {
group = {
cliente: client,
totalLinhas: 0,
ativos: 0,
bloqueados: 0
};
grouped.set(key, group);
}
group.totalLinhas += 1;
const status = ((row?.status ?? '').toString().trim()).toLowerCase();
if (status.includes('ativo')) group.ativos += 1;
if (this.isBlockedStatus(row?.status ?? '')) {
group.bloqueados += 1;
}
}
return this.sortGroupsWithReservaFirst(Array.from(grouped.values()));
}
private sortGroupsWithReservaFirst(groups: ClientGroupDto[]): ClientGroupDto[] {
const list = Array.isArray(groups) ? [...groups] : [];
return list.sort((a, b) => {
const aEstoque = this.isStockClientName(a?.cliente);
const bEstoque = this.isStockClientName(b?.cliente);
if (aEstoque !== bEstoque) return aEstoque ? -1 : 1;
const aReserva = (a?.cliente || '').trim().localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0;
const bReserva = (b?.cliente || '').trim().localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0;
if (aReserva !== bReserva) return aReserva ? -1 : 1;
return (a?.cliente || '').localeCompare((b?.cliente || ''), 'pt-BR', { sensitivity: 'base' });
});
}
toggleGroup(clientName: string) {
if (this.expandedGroup === clientName) {
this.resetGroupSearchState();
this.expandedGroup = null;
this.groupLines = [];
this.clearReservaSelection();
return;
}
this.resetGroupSearchState();
this.clearReservaSelection();
this.expandedGroup = clientName;
this.fetchGroupLines(clientName, this.getActiveExpandedGroupSearchTerm());
}
private async fetchAllGroupLines(
clientName: string,
search: string | undefined,
requestVersion: number
): Promise<void> {
try {
let baseParams = new HttpParams()
.set('client', clientName)
.set('sortBy', 'item')
.set('sortDir', 'asc');
baseParams = this.applyBaseFilters(baseParams);
if (search) {
baseParams = baseParams.set('search', search);
}
const pageSize = 5000;
let page = 1;
let expectedTotal = 0;
const allItems: ApiLineList[] = [];
while (page <= 500) {
const params = baseParams
.set('page', String(page))
.set('pageSize', String(pageSize));
const response = await firstValueFrom(
this.http.get<ApiPagedResult<ApiLineList>>(this.apiBase, {
params: this.withNoCache(params)
})
);
if (requestVersion !== this.linesRequestVersion) return;
const items = response?.items ?? [];
expectedTotal = this.toInt(response?.total);
allItems.push(...items);
if (items.length === 0) break;
if (items.length < pageSize) break;
if (expectedTotal > 0 && allItems.length >= expectedTotal) break;
page += 1;
}
if (requestVersion !== this.linesRequestVersion) return;
const filteredItems = this.applyAdditionalFiltersClientSide(allItems);
this.groupLines = filteredItems.map((x) => this.mapApiLineListToLineRow(x));
this.loadingLines = false;
this.cdr.detectChanges();
} catch {
if (requestVersion !== this.linesRequestVersion) return;
this.loadingLines = false;
await this.showToast('Erro ao carregar linhas do grupo.');
}
}
private mapApiLineListToLineRow(x: ApiLineList): LineRow {
return {
id: x.id,
item: String(x.item ?? ''),
linha: x.linha ?? '',
chip: x.chip ?? '',
cliente: x.cliente ?? '',
usuario: x.usuario ?? '',
centroDeCustos: x.centroDeCustos ?? '',
setorNome: x.setorNome ?? '',
aparelhoNome: x.aparelhoNome ?? '',
aparelhoCor: x.aparelhoCor ?? '',
status: x.status ?? '',
skil: x.skil ?? '',
contrato: x.vencConta ?? ''
};
}
fetchGroupLines(clientName: string, search?: string) {
const requestVersion = ++this.linesRequestVersion;
this.groupLines = [];
this.clearReservaSelection();
this.loadingLines = true;
const normalizedSearch = (search ?? '').trim() || undefined;
void this.fetchAllGroupLines(clientName, normalizedSearch, requestVersion);
}
toggleClientMenu() {
if (this.isClientRestricted) return;
if (this.showClientMenu) {
this.closeClientDropdown();
return;
}
this.dropdownCoordinator.requestOpen(this.clientDropdownId);
this.showClientMenu = true;
}
toggleAdditionalMenu() {
if (this.isClientRestricted) return;
if (this.showAdditionalMenu) {
this.closeAdditionalDropdown();
return;
}
this.dropdownCoordinator.requestOpen(this.additionalDropdownId);
this.showAdditionalMenu = true;
}
closeClientDropdown(notifyCoordinator = true) {
this.showClientMenu = false;
if (notifyCoordinator) {
this.dropdownCoordinator.clear(this.clientDropdownId);
}
}
closeAdditionalDropdown(notifyCoordinator = true) {
this.showAdditionalMenu = false;
if (notifyCoordinator) {
this.dropdownCoordinator.clear(this.additionalDropdownId);
}
}
closeFilterDropdowns() {
this.closeClientDropdown();
this.closeAdditionalDropdown();
}
selectClient(client: string | null) {
if (this.isClientRestricted) return;
if (client === null) {
this.selectedClients = [];
} else {
const idx = this.selectedClients.indexOf(client);
if (idx >= 0) this.selectedClients.splice(idx, 1);
else this.selectedClients.push(client);
}
this.page = 1;
this.expandedGroup = null;
this.searchResolvedClient = null;
this.refreshData();
}
removeClient(client: string, event: Event) {
if (this.isClientRestricted) return;
event.stopPropagation();
const idx = this.selectedClients.indexOf(client);
if (idx >= 0) {
this.selectedClients.splice(idx, 1);
this.page = 1;
this.searchResolvedClient = null;
this.refreshData();
}
}
clearClientSelection(event?: Event) {
if (this.isClientRestricted) return;
if (event) event.stopPropagation();
this.selectedClients = [];
this.clientSearchTerm = '';
this.page = 1;
this.searchResolvedClient = null;
this.refreshData();
}
isClientSelected(client: string): boolean {
return this.selectedClients.includes(client);
}
get filteredClientsList(): string[] {
if (!this.clientSearchTerm) return this.clientsList;
const s = this.clientSearchTerm.toLowerCase();
return this.clientsList.filter((c) => c.toLowerCase().includes(s));
}
setSort(key: keyof LineRow) {
if (this.sortKey === key) this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
else {
this.sortKey = key;
this.sortDir = 'asc';
}
this.page = 1;
this.loadGroups();
}
onPageSizeChange() {
this.page = 1;
this.refreshData();
}
goToPage(p: number) {
this.page = clampPage(p, this.totalPages);
this.refreshData({ keepCurrentPage: true });
}
trackById(_: number, row: LineRow) {
return row.id;
}
get pagedRows() {
return this.rows;
}
get totalPages() {
if (this.selectedClients.length > 0) return 1;
if (this.searchResolvedClient) return 1;
return computeTotalPages(this.total || 0, this.pageSize);
}
get filteredCount() {
return this.total || 0;
}
get pageStart() {
return computePageStart(this.filteredCount, this.page, this.pageSize);
}
get pageEnd() {
if (this.selectedClients.length > 0 || this.searchResolvedClient) {
return this.filteredCount;
}
return this.filteredCount === 0
? 0
: Math.min(
(this.page - 1) * this.pageSize +
(this.isGroupMode ? this.clientGroups.length : this.rows.length),
this.filteredCount
);
}
get pageNumbers() {
return buildPageNumbers(this.page, this.totalPages);
}
clearSearch() {
this.searchTerm = '';
this.searchResolvedClient = null;
this.resetGroupSearchState();
this.expandedGroup = null;
this.groupLines = [];
this.page = 1;
this.refreshData();
}
async onExport(): Promise<void> {
if (this.exporting) return;
this.exporting = true;
try {
const baseRows = await this.getRowsForExport();
const rows = await this.getDetailedRowsForExport(baseRows);
if (!rows.length) {
await this.showToast('Nenhum registro encontrado para exportar.');
return;
}
const suffix = this.getExportFilterSuffix();
const timestamp = this.tableExportService.buildTimestamp();
const fileName = `geral_${suffix}_${timestamp}`;
await this.tableExportService.exportAsXlsx<ApiLineDetail>({
fileName,
sheetName: 'Geral',
rows,
columns: [
{ header: 'ID', value: (row) => row.id },
{ header: 'Item', type: 'number', value: (row) => this.toInt(row.item) },
{ header: 'Empresa (Conta)', value: (row) => this.findEmpresaByConta(row.conta) },
{ header: 'Conta', value: (row) => row.conta ?? '' },
{ header: 'Linha', value: (row) => row.linha ?? '' },
{ header: 'Chip', value: (row) => row.chip ?? '' },
{ header: 'Tipo de Chip', value: (row) => row.tipoDeChip ?? '' },
{ header: 'Cliente', value: (row) => row.cliente ?? '' },
{ header: 'Usuario', value: (row) => row.usuario ?? '' },
{ header: 'Centro de Custos', value: (row) => row.centroDeCustos ?? '' },
{ header: 'Setor ID', value: (row) => row.setorId ?? '' },
{ header: 'Setor', value: (row) => row.setorNome ?? '' },
{ header: 'Aparelho ID', value: (row) => row.aparelhoId ?? '' },
{ header: 'Aparelho', value: (row) => row.aparelhoNome ?? '' },
{ header: 'Cor do Aparelho', value: (row) => row.aparelhoCor ?? '' },
{ header: 'IMEI do Aparelho', value: (row) => row.aparelhoImei ?? '' },
{ header: 'NF do Aparelho (anexo)', type: 'boolean', value: (row) => !!row.aparelhoNotaFiscalTemArquivo },
{ header: 'Recibo do Aparelho (anexo)', type: 'boolean', value: (row) => !!row.aparelhoReciboTemArquivo },
{ header: 'Plano Contrato', value: (row) => row.planoContrato ?? '' },
{ header: 'Status', value: (row) => row.status ?? '' },
{ header: 'Tipo (Skil)', value: (row) => row.skil ?? '' },
{ header: 'Modalidade', value: (row) => row.modalidade ?? '' },
{ header: 'Cedente', value: (row) => row.cedente ?? '' },
{ header: 'Solicitante', value: (row) => row.solicitante ?? '' },
{ header: 'Data de Bloqueio', type: 'date', value: (row) => row.dataBloqueio ?? '' },
{ header: 'Data Entrega Operadora', type: 'date', value: (row) => row.dataEntregaOpera ?? '' },
{ header: 'Data Entrega Cliente', type: 'date', value: (row) => row.dataEntregaCliente ?? '' },
{ header: 'Dt. Efetivacao Servico', type: 'date', value: (row) => row.dtEfetivacaoServico ?? '' },
{ header: 'Dt. Termino Fidelizacao', type: 'date', value: (row) => row.dtTerminoFidelizacao ?? '' },
{ header: 'Vencimento da Conta', value: (row) => row.vencConta ?? '' },
{ header: 'Franquia Vivo', type: 'number', value: (row) => this.toNullableNumber(row.franquiaVivo) ?? 0 },
{ header: 'Valor Plano Vivo', type: 'currency', value: (row) => this.toNullableNumber(row.valorPlanoVivo) ?? 0 },
{ header: 'Gestao Voz e Dados', type: 'currency', value: (row) => this.toNullableNumber(row.gestaoVozDados) ?? 0 },
{ header: 'Skeelo', type: 'currency', value: (row) => this.toNullableNumber(row.skeelo) ?? 0 },
{ header: 'Vivo News Plus', type: 'currency', value: (row) => this.toNullableNumber(row.vivoNewsPlus) ?? 0 },
{ header: 'Vivo Travel Mundo', type: 'currency', value: (row) => this.toNullableNumber(row.vivoTravelMundo) ?? 0 },
{ header: 'Vivo Gestao Dispositivo', type: 'currency', value: (row) => this.toNullableNumber(row.vivoGestaoDispositivo) ?? 0 },
{ header: 'Vivo Sync', type: 'currency', value: (row) => this.toNullableNumber(row.vivoSync) ?? 0 },
{ header: 'Valor Contrato Vivo', type: 'currency', value: (row) => this.toNullableNumber(row.valorContratoVivo) ?? 0 },
{ header: 'Franquia Line', type: 'number', value: (row) => this.toNullableNumber(row.franquiaLine) ?? 0 },
{ header: 'Franquia Gestao', type: 'number', value: (row) => this.toNullableNumber(row.franquiaGestao) ?? 0 },
{ header: 'Locacao AP', type: 'currency', value: (row) => this.toNullableNumber(row.locacaoAp) ?? 0 },
{ header: 'Valor Contrato Line', type: 'currency', value: (row) => this.toNullableNumber(row.valorContratoLine) ?? 0 },
{ header: 'Desconto', type: 'currency', value: (row) => this.toNullableNumber(row.desconto) ?? 0 },
{ header: 'Lucro', type: 'currency', value: (row) => this.toNullableNumber(row.lucro) ?? 0 },
{ header: 'Criado Em', type: 'datetime', value: (row) => this.getAnyField(row, ['createdAt', 'CreatedAt']) ?? '' },
{ header: 'Atualizado Em', type: 'datetime', value: (row) => this.getAnyField(row, ['updatedAt', 'UpdatedAt']) ?? '' },
],
});
await this.showToast(`Planilha exportada com ${rows.length} registro(s).`);
} catch {
await this.showToast('Erro ao exportar a planilha.');
} finally {
this.exporting = false;
}
}
async onExportTemplate(): Promise<void> {
if (this.exportingTemplate) return;
this.exportingTemplate = true;
try {
await this.importPageTemplateService.exportGeralTemplate();
await this.showToast('Modelo da página exportado.');
} catch {
await this.showToast('Erro ao exportar o modelo da página.');
} finally {
this.exportingTemplate = false;
}
}
private async getRowsForExport(): Promise<LineRow[]> {
let lines = await this.fetchLinesForGrouping();
if (this.selectedClients.length > 0) {
const selected = new Set(
this.selectedClients.map((client) => (client ?? '').toString().trim().toUpperCase())
);
lines = lines.filter((line) => selected.has((line.cliente ?? '').toString().trim().toUpperCase()));
}
const fallbackClient = this.getClientFallbackLabel('SEM CLIENTE');
const mapped = lines.map((line) => ({
id: (line.id ?? '').toString(),
item: String(line.item ?? ''),
linha: line.linha ?? '',
chip: line.chip ?? '',
cliente: ((line.cliente ?? '').toString().trim()) || fallbackClient,
usuario: line.usuario ?? '',
centroDeCustos: line.centroDeCustos ?? '',
setorNome: line.setorNome ?? '',
aparelhoNome: line.aparelhoNome ?? '',
aparelhoCor: line.aparelhoCor ?? '',
status: line.status ?? '',
skil: line.skil ?? '',
contrato: line.vencConta ?? '',
}));
return mapped.sort((a, b) => {
const byClient = (a.cliente ?? '').localeCompare(b.cliente ?? '', 'pt-BR', { sensitivity: 'base' });
if (byClient !== 0) return byClient;
const byItem = this.toInt(a.item) - this.toInt(b.item);
if (byItem !== 0) return byItem;
return (a.linha ?? '').localeCompare(b.linha ?? '', 'pt-BR', { sensitivity: 'base' });
});
}
private async getDetailedRowsForExport(baseRows: LineRow[]): Promise<ApiLineDetail[]> {
if (!baseRows.length) return [];
try {
const ids = Array.from(
new Set(
baseRows
.map((row) => (row.id ?? '').toString().trim())
.filter((id) => !!id)
)
);
const fetched = await firstValueFrom(
this.http.post<ApiLineDetail[]>(`${this.apiBase}/export-details`, { ids })
);
if (Array.isArray(fetched) && fetched.length > 0) {
return fetched;
}
} catch {
// Fallback local preserva a exportacao se o endpoint em lote falhar.
}
return baseRows.map((row) => this.toDetailFallback(row));
}
private toDetailFallback(row: LineRow): ApiLineDetail {
return {
id: row.id,
item: this.toInt(row.item),
qtdLinhas: null,
conta: row.contrato ?? null,
linha: row.linha ?? null,
chip: row.chip ?? null,
tipoDeChip: null,
cliente: row.cliente ?? null,
usuario: row.usuario ?? null,
centroDeCustos: row.centroDeCustos ?? null,
setorId: null,
setorNome: row.setorNome ?? null,
aparelhoId: null,
aparelhoNome: row.aparelhoNome ?? null,
aparelhoCor: row.aparelhoCor ?? null,
aparelhoImei: null,
aparelhoNotaFiscalTemArquivo: false,
aparelhoReciboTemArquivo: false,
planoContrato: null,
status: row.status ?? null,
skil: row.skil ?? null,
modalidade: null,
dataBloqueio: null,
cedente: null,
solicitante: null,
dataEntregaOpera: null,
dataEntregaCliente: null,
dtEfetivacaoServico: null,
dtTerminoFidelizacao: null,
vencConta: row.contrato ?? null,
franquiaVivo: null,
valorPlanoVivo: null,
gestaoVozDados: null,
skeelo: null,
vivoNewsPlus: null,
vivoTravelMundo: null,
vivoGestaoDispositivo: null,
vivoSync: null,
valorContratoVivo: null,
franquiaLine: null,
franquiaGestao: null,
locacaoAp: null,
valorContratoLine: null,
desconto: null,
lucro: null,
createdAt: null,
updatedAt: null,
};
}
private getExportFilterSuffix(): string {
const parts: string[] = [];
if (this.filterSkil === 'PF') parts.push('pf');
else if (this.filterSkil === 'PJ') parts.push('pj');
else if (this.filterSkil === 'RESERVA') parts.push('reservas');
else if (this.filterSkil === 'ESTOQUE') parts.push('estoque');
else parts.push('todas');
if (this.filterStatus === 'ACTIVE') {
parts.push('ativas');
} else if (this.filterStatus === 'BLOCKED') {
if (this.blockedStatusMode === 'PERDA_ROUBO') parts.push('bloq-perda-roubo');
else if (this.blockedStatusMode === 'BLOQUEIO_120') parts.push('bloq-120');
else if (this.blockedStatusMode === 'PRE_ATIVACAO') parts.push('bloq-pre-ativacao');
else parts.push('bloqueadas');
}
if (this.additionalMode === 'WITH') parts.push('com-adicionais');
else if (this.additionalMode === 'WITHOUT') parts.push('sem-adicionais');
if (this.selectedAdditionalServices.length > 0) {
parts.push(this.selectedAdditionalServices.join('-'));
}
if (this.filterOperadora !== 'ALL') {
parts.push(`operadora-${this.filterOperadora.toLowerCase()}`);
}
if (this.filterContaEmpresa.trim()) {
parts.push(`empresa-${this.normalizeFilterToken(this.filterContaEmpresa).toLowerCase()}`);
}
return parts.join('_');
}
async onImportExcel() {
if (!this.isSysAdmin) {
await this.showToast('Você não tem permissão para importar planilha.');
return;
}
if (!this.excelInput?.nativeElement) return;
this.excelInput.nativeElement.value = '';
this.excelInput.nativeElement.click();
}
onExcelSelected(ev: Event) {
if (!this.isSysAdmin) return;
const file = (ev.target as HTMLInputElement).files?.[0];
if (!file) return;
const form = new FormData();
form.append('file', file);
this.loading = true;
this.http.post<{ imported: number }>(`${this.apiBase}/import-excel`, form).subscribe({
next: async (r) => {
await this.showToast(`Sucesso! ${r?.imported ?? 0} registros importados.`);
this.page = 1;
this.refreshData();
},
error: async () => {
this.loading = false;
await this.showToast('Falha ao importar planilha.');
}
});
}
async openMveAuditModal() {
if (!this.canManageLines) {
await this.showToast('Você não tem permissão para auditar linhas com o relatório MVE.');
return;
}
this.mveAuditOpen = true;
this.mveAuditFile = null;
this.mveAuditResult = null;
this.mveAuditError = '';
this.mveAuditFilter = 'ALL';
this.mveAuditSearchTerm = '';
this.mveAuditPage = 1;
this.mveAuditApplyMode = 'ALL_SYNCABLE';
this.mveAuditApplyConfirmOpen = false;
this.mveAuditApplyLastResult = null;
this.cdr.detectChanges();
}
onMveAuditFileSelected(event: Event) {
const file = (event.target as HTMLInputElement | null)?.files?.[0] ?? null;
this.mveAuditError = '';
this.mveAuditApplyLastResult = null;
if (!file) {
this.mveAuditFile = null;
return;
}
if (!file.name.toLowerCase().endsWith('.csv')) {
this.mveAuditFile = null;
this.mveAuditError = 'Selecione um arquivo CSV exportado do MVE da Vivo.';
return;
}
if (file.size <= 0) {
this.mveAuditFile = null;
this.mveAuditError = 'O arquivo selecionado está vazio.';
return;
}
if (file.size > 20 * 1024 * 1024) {
this.mveAuditFile = null;
this.mveAuditError = 'O arquivo do MVE excede o limite de 20 MB.';
return;
}
this.mveAuditFile = file;
}
clearMveAuditFile() {
this.mveAuditFile = null;
this.mveAuditError = '';
}
async submitMveAudit() {
if (!this.canSubmitMveAudit || !this.mveAuditFile) {
return;
}
this.mveAuditProcessing = true;
this.mveAuditError = '';
this.mveAuditResult = null;
this.mveAuditApplyLastResult = null;
try {
this.mveAuditResult = await firstValueFrom(this.mveAuditService.preview(this.mveAuditFile));
this.mveAuditFilter = 'ALL';
this.mveAuditSearchTerm = '';
this.mveAuditPage = 1;
await this.showToast('Auditoria MVE processada com sucesso.');
} catch (err) {
this.mveAuditError = this.extractHttpMessage(err, 'Não foi possível processar o relatório MVE.');
} finally {
this.mveAuditProcessing = false;
this.cdr.detectChanges();
}
}
setMveAuditFilter(filter: MveAuditFilterMode) {
this.mveAuditFilter = filter;
this.mveAuditPage = 1;
}
onMveAuditSearchChange() {
this.mveAuditPage = 1;
}
onMveAuditPageSizeChange() {
this.mveAuditPage = 1;
}
goToMveAuditPage(page: number) {
this.mveAuditPage = clampPage(page, this.mveAuditTotalPages);
}
openMveApplyConfirm(mode: MveAuditApplyMode) {
if (!this.hasMveAuditResult) return;
this.mveAuditApplyMode = mode;
if (!this.canOpenMveApplyConfirm) return;
this.mveAuditApplyConfirmOpen = true;
}
closeMveApplyConfirm() {
this.mveAuditApplyConfirmOpen = false;
}
async confirmMveApply() {
if (!this.mveAuditResult || !this.canOpenMveApplyConfirm) {
return;
}
const selectedIssues = this.selectedMveApplyIssues;
this.mveAuditApplying = true;
try {
const result = await firstValueFrom(
this.mveAuditService.apply(
this.mveAuditResult.id,
this.mveAuditApplyMode === 'FILTERED_SYNCABLE' ? selectedIssues.map((issue) => issue.id) : undefined
)
);
this.mveAuditApplyLastResult = result;
this.mveAuditResult = await firstValueFrom(this.mveAuditService.getById(this.mveAuditResult.id));
this.mveAuditApplyConfirmOpen = false;
const label =
result.updatedLines > 0
? `${result.updatedLines} linha(s) atualizada(s) com base no MVE.`
: 'Nenhuma linha precisou ser alterada com a sincronização MVE.';
await this.showToast(label);
this.refreshData({ keepCurrentPage: true });
} catch (err) {
await this.showToast(this.extractHttpMessage(err, 'Não foi possível aplicar a sincronização MVE.'));
} finally {
this.mveAuditApplying = false;
this.cdr.detectChanges();
}
}
async exportMveAuditIssues() {
if (!this.hasMveAuditResult || this.filteredMveAuditIssues.length === 0) {
await this.showToast('Não há inconsistências filtradas para exportar.');
return;
}
const headers = [
'Numero da linha',
'Item sistema',
'Situacao',
'Tipo',
'Status sistema',
'Status relatorio',
'Plano sistema',
'Plano relatorio',
'Diferencas',
'Acao sugerida',
'Observacoes',
];
const rows = this.filteredMveAuditIssues.map((issue) =>
[
issue.numeroLinha || '',
issue.systemItem != null ? String(issue.systemItem) : '',
issue.situation || '',
issue.issueType || '',
issue.systemStatus || '',
issue.reportStatus || '',
issue.systemPlan || '',
issue.reportPlan || '',
this.describeMveDifferences(issue),
issue.actionSuggestion || '',
issue.notes || '',
]
.map((value) => this.escapeCsvValue(value))
.join(';')
);
const content = `${headers.join(';')}\n${rows.join('\n')}`;
const blob = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8;' });
const timestamp = this.tableExportService.buildTimestamp();
this.downloadBlob(blob, `mve_auditoria_${timestamp}.csv`);
await this.showToast(`CSV exportado com ${rows.length} inconsistência(s).`);
}
describeMveDifferences(issue: MveAuditIssue): string {
const differences = issue.differences ?? [];
if (!differences.length) {
return issue.notes ?? '-';
}
return differences
.map((diff) => `${diff.label}: ${diff.systemValue ?? '-'} -> ${diff.reportValue ?? '-'}`)
.join(' | ');
}
getMveIssueTagClass(issue: MveAuditIssue): string {
switch (issue.issueType) {
case 'STATUS_DIVERGENCE':
case 'STATUS_AND_DATA_DIVERGENCE':
return 'status';
case 'CHIP_CHANGE_DETECTED':
case 'LINE_CHANGE_DETECTED':
case 'DATA_DIVERGENCE':
return 'data';
case 'ONLY_IN_SYSTEM':
return 'system';
case 'ONLY_IN_REPORT':
return 'report';
case 'DUPLICATE_REPORT':
case 'DUPLICATE_SYSTEM':
return 'duplicate';
case 'INVALID_ROW':
case 'DDD_CHANGE_REVIEW':
case 'UNKNOWN_STATUS':
return 'warning';
default:
return 'neutral';
}
}
getMveSeverityClass(severity: string | null | undefined): string {
const normalized = (severity ?? '').toString().trim().toUpperCase();
if (normalized === 'HIGH') return 'critical';
if (normalized === 'MEDIUM') return 'medium';
if (normalized === 'WARNING') return 'warning';
return 'neutral';
}
private getById(id: string, cb: (d: any) => void) {
this.http.get(`${this.apiBase}/${id}`).subscribe({
next: cb,
error: () => this.showToast('Erro ao carregar detalhes.')
});
}
async onDetalhes(r: LineRow) {
this.detailOpen = true;
this.detailData = null;
this.cdr.detectChanges();
this.getById(r.id, (d) => {
this.detailData = {
...d,
contaEmpresa: this.findEmpresaByConta(d?.conta)
};
this.syncContaEmpresaSelection(this.detailData);
this.cdr.detectChanges();
});
}
async onFinanceiro(r: LineRow) {
this.financeOpen = true;
this.financeData = null;
this.cdr.detectChanges();
this.getById(r.id, (d) => {
this.financeData = d;
this.cdr.detectChanges();
});
}
async onEditar(r: LineRow) {
if (this.isFinanceiro) {
await this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
this.editOpen = true;
this.editSaving = false;
this.requestSaving = false;
this.editModel = null;
this.aparelhoNotaFiscalFile = null;
this.aparelhoReciboFile = null;
this.editingId = r.id;
this.cdr.detectChanges();
this.http.get<ApiLineDetail>(`${this.apiBase}/${r.id}`).subscribe({
next: (d) => {
this.editModel = this.toEditModel(d);
this.syncContaEmpresaSelection(this.editModel);
this.cdr.detectChanges();
},
error: async () => {
this.closeAllModals();
await this.showToast('Erro ao carregar dados para edição.');
}
});
}
onPlanoChange(isEdit: boolean) {
const model = isEdit ? this.editModel : this.createModel;
if (!model) return;
const plan = (model.planoContrato ?? '').toString().trim();
if (!plan) return;
const suggestion = this.planAutoFill.suggest(plan);
if (!suggestion) return;
if (suggestion.franquiaGb != null) {
model.franquiaVivo = suggestion.franquiaGb;
if (model.franquiaLine === null || model.franquiaLine === undefined || model.franquiaLine === '') {
model.franquiaLine = suggestion.franquiaGb;
}
}
if (suggestion.valorPlano != null) {
model.valorPlanoVivo = suggestion.valorPlano;
}
this.onFinancialChange(isEdit);
}
calculateFinancials(model: any) {
if (!model) return;
const parse = (v: any): number => {
if (v === null || v === undefined || v === '') return 0;
if (typeof v === 'number') return v;
return parseFloat(v.toString().replace(',', '.')) || 0;
};
const valorPlano = parse(model.valorPlanoVivo);
const gestaoVoz = parse(model.gestaoVozDados);
const skeelo = parse(model.skeelo);
const news = parse(model.vivoNewsPlus);
const travel = parse(model.vivoTravelMundo);
const gestaoDisp = parse(model.vivoGestaoDispositivo);
const vivoSync = parse(model.vivoSync);
const totalVivo = valorPlano + gestaoVoz + skeelo + news + travel + gestaoDisp + vivoSync;
model.valorContratoVivo = parseFloat(totalVivo.toFixed(2));
const totalLineManual = parse(model.valorContratoLine);
const lucro = totalLineManual - totalVivo;
model.lucro = parseFloat(lucro.toFixed(2));
model.desconto = model.desconto ? parse(model.desconto) : 0.0;
}
onFinancialChange(isEdit: boolean) {
const model = isEdit ? this.editModel : this.createModel;
this.calculateFinancials(model);
}
async saveEdit() {
if (this.isFinanceiro) {
await this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
if (!this.editingId || !this.editModel || this.requestSaving) return;
this.editSaving = true;
const editingId = this.editingId;
const shouldUploadAttachments = !!(this.aparelhoNotaFiscalFile || this.aparelhoReciboFile);
const franquiaLineAtual = this.toNullableNumber(this.editModel.franquiaLine);
const franquiaLineSolicitada = this.toNullableNumber(this.editModel.franquiaLineSolicitada);
const shouldRequestFranquia =
this.isClientRestricted && !this.nullableNumberEquals(franquiaLineAtual, franquiaLineSolicitada);
let payload: UpdateMobileLineRequest;
if (this.isClientRestricted) {
payload = {
item: this.toInt(this.editModel.item),
usuario: (this.editModel.usuario ?? '').toString(),
centroDeCustos: (this.editModel.centroDeCustos ?? '').toString(),
setorNome: (this.editModel.setorNome ?? '').toString(),
aparelhoId: (this.editModel.aparelhoId ?? null) as string | null,
aparelhoNome: (this.editModel.aparelhoNome ?? '').toString(),
aparelhoCor: (this.editModel.aparelhoCor ?? '').toString(),
aparelhoImei: (this.editModel.aparelhoImei ?? '').toString(),
franquiaLine: franquiaLineAtual
};
} else {
const contaEmpresaValidationMessage = this.validateContaEmpresaBinding(this.editModel);
if (contaEmpresaValidationMessage) {
this.editSaving = false;
await this.showToast(contaEmpresaValidationMessage);
return;
}
this.calculateFinancials(this.editModel);
const {
contaEmpresa: _contaEmpresa,
franquiaLineSolicitada: _franquiaLineSolicitada,
...editModelPayload
} = this.editModel;
payload = {
...editModelPayload,
item: this.toInt(this.editModel.item),
dataBloqueio: this.dateInputToIso(this.editModel.dataBloqueio),
dataEntregaOpera: this.dateInputToIso(this.editModel.dataEntregaOpera),
dataEntregaCliente: this.dateInputToIso(this.editModel.dataEntregaCliente),
dtEfetivacaoServico: this.dateInputToIso(this.editModel.dtEfetivacaoServico),
dtTerminoFidelizacao: this.dateInputToIso(this.editModel.dtTerminoFidelizacao),
vencConta: (this.editModel.vencConta ?? '').toString(),
franquiaVivo: this.toNullableNumber(this.editModel.franquiaVivo),
valorPlanoVivo: this.toNullableNumber(this.editModel.valorPlanoVivo),
gestaoVozDados: this.toNullableNumber(this.editModel.gestaoVozDados),
skeelo: this.toNullableNumber(this.editModel.skeelo),
vivoNewsPlus: this.toNullableNumber(this.editModel.vivoNewsPlus),
vivoTravelMundo: this.toNullableNumber(this.editModel.vivoTravelMundo),
vivoGestaoDispositivo: this.toNullableNumber(this.editModel.vivoGestaoDispositivo),
vivoSync: this.toNullableNumber(this.editModel.vivoSync),
valorContratoVivo: this.toNullableNumber(this.editModel.valorContratoVivo),
franquiaLine: this.toNullableNumber(this.editModel.franquiaLine),
franquiaGestao: this.toNullableNumber(this.editModel.franquiaGestao),
locacaoAp: this.toNullableNumber(this.editModel.locacaoAp),
valorContratoLine: this.toNullableNumber(this.editModel.valorContratoLine),
desconto: this.toNullableNumber(this.editModel.desconto),
lucro: this.toNullableNumber(this.editModel.lucro),
tipoDeChip: (this.editModel.tipoDeChip ?? '').toString()
};
}
this.http.put(`${this.apiBase}/${editingId}`, payload).subscribe({
next: async () => {
try {
if (shouldUploadAttachments) {
await this.uploadAparelhoAnexos(editingId);
}
} catch {
this.editSaving = false;
await this.showToast('Registro salvo, mas falhou o upload dos anexos.');
return;
}
if (shouldRequestFranquia) {
try {
await firstValueFrom(this.solicitacoesLinhasService.create({
lineId: editingId,
tipoSolicitacao: 'alteracao-franquia',
franquiaLineNova: franquiaLineSolicitada
}));
} catch (err) {
this.editSaving = false;
this.closeAllModals();
const msg =
(err as HttpErrorResponse)?.error?.message
|| 'Registro atualizado, mas falhou ao enviar a solicitação de franquia.';
await this.showToast(msg);
if (this.isGroupMode && this.expandedGroup) {
this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm());
this.loadGroups();
this.loadKpis();
} else {
this.refreshData();
}
return;
}
}
this.editSaving = false;
// fecha e limpa overlay SEMPRE
this.closeAllModals();
if (shouldRequestFranquia) {
await this.showToast(
shouldUploadAttachments
? 'Registro e anexos atualizados! Solicitação de franquia enviada.'
: 'Registro atualizado! Solicitação de franquia enviada.'
);
} else {
await this.showToast(shouldUploadAttachments ? 'Registro e anexos atualizados!' : 'Registro atualizado!');
}
if (this.isGroupMode && this.expandedGroup) {
this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm());
this.loadGroups();
this.loadKpis();
} else {
this.refreshData();
}
},
error: async () => {
this.editSaving = false;
await this.showToast('Erro ao salvar.');
}
});
}
async requestLineBlock() {
if (!this.isClientRestricted) {
await this.showToast('Somente cliente pode solicitar bloqueio por essa ação.');
return;
}
if (!this.editingId || this.requestSaving) return;
this.requestSaving = true;
try {
await firstValueFrom(this.solicitacoesLinhasService.create({
lineId: this.editingId,
tipoSolicitacao: 'bloqueio'
}));
this.requestSaving = false;
this.closeAllModals();
await this.showToast('Solicitação de bloqueio enviada.');
} catch (err) {
this.requestSaving = false;
const msg = (err as HttpErrorResponse)?.error?.message || 'Erro ao enviar solicitação de bloqueio.';
await this.showToast(msg);
}
}
onAparelhoNotaFiscalSelected(event: Event) {
const input = event.target as HTMLInputElement | null;
this.aparelhoNotaFiscalFile = input?.files && input.files.length > 0 ? input.files[0] : null;
}
onAparelhoReciboSelected(event: Event) {
const input = event.target as HTMLInputElement | null;
this.aparelhoReciboFile = input?.files && input.files.length > 0 ? input.files[0] : null;
}
async downloadAparelhoAnexo(tipo: 'nota-fiscal' | 'recibo', lineId?: string | null) {
const targetLineId = (lineId ?? this.detailData?.id ?? this.editingId ?? '').toString().trim();
if (!targetLineId) {
await this.showToast('Linha inválida para download do anexo.');
return;
}
try {
const response = await firstValueFrom(
this.http.get(`${this.apiBase}/${targetLineId}/aparelho/anexos/${tipo}`, {
observe: 'response',
responseType: 'blob'
})
);
const blob = response.body;
if (!blob) {
await this.showToast('Arquivo de anexo não encontrado.');
return;
}
const disposition = response.headers.get('content-disposition');
const contentType = (response.headers.get('content-type') || blob.type || '').toLowerCase();
const fallbackName = tipo === 'nota-fiscal' ? 'nota-fiscal' : 'recibo';
const fileName = this.extractAttachmentFileName(disposition, fallbackName, contentType);
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
} catch {
await this.showToast('Não foi possível baixar o anexo.');
}
}
private async uploadAparelhoAnexos(lineId: string): Promise<void> {
const hasAnyFile = !!this.aparelhoNotaFiscalFile || !!this.aparelhoReciboFile;
if (!hasAnyFile) {
return;
}
const formData = new FormData();
if (this.aparelhoNotaFiscalFile) {
formData.append('notaFiscal', this.aparelhoNotaFiscalFile, this.aparelhoNotaFiscalFile.name);
}
if (this.aparelhoReciboFile) {
formData.append('recibo', this.aparelhoReciboFile, this.aparelhoReciboFile.name);
}
await firstValueFrom(this.http.post(`${this.apiBase}/${lineId}/aparelho/anexos`, formData));
this.aparelhoNotaFiscalFile = null;
this.aparelhoReciboFile = null;
}
private extractAttachmentFileName(
contentDisposition: string | null,
fallbackBaseName: string,
contentType: string
): string {
const raw = (contentDisposition ?? '').trim();
const inferredExtension = this.extensionFromContentType(contentType) ?? '.pdf';
if (!raw) {
return `${fallbackBaseName}${inferredExtension}`;
}
const utf8Match = raw.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
return this.ensureFileNameExtension(decodeURIComponent(utf8Match[1]), inferredExtension);
}
const simpleMatch = raw.match(/filename="?([^";]+)"?/i);
if (simpleMatch?.[1]) {
return this.ensureFileNameExtension(simpleMatch[1], inferredExtension);
}
return `${fallbackBaseName}${inferredExtension}`;
}
private extensionFromContentType(contentType: string): string | null {
if (!contentType) return null;
if (contentType.includes('application/pdf')) return '.pdf';
if (contentType.includes('image/png')) return '.png';
if (contentType.includes('image/jpeg') || contentType.includes('image/jpg')) return '.jpg';
if (contentType.includes('image/webp')) return '.webp';
return null;
}
private ensureFileNameExtension(fileName: string, fallbackExtension: string): string {
const normalized = (fileName ?? '').trim();
if (!normalized) return `anexo${fallbackExtension}`;
const hasExtension = /\.[A-Za-z0-9]{2,6}$/.test(normalized);
return hasExtension ? normalized : `${normalized}${fallbackExtension}`;
}
async onRemover(r: LineRow, fromGroup = false) {
if (!this.isSysAdmin) {
await this.showToast('Apenas sysadmin pode remover linhas.');
return;
}
if (!(await confirmDeletionWithTyping(`a linha ${r.linha}`))) return;
this.loading = true;
this.http.delete(`${this.apiBase}/${r.id}`).subscribe({
next: async () => {
await this.showToast('Removido com sucesso.');
this.loading = false;
if (fromGroup && this.expandedGroup) {
this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm());
this.loadGroups();
this.loadKpis();
} else {
this.refreshData();
}
},
error: async () => {
this.loading = false;
await this.showToast('Erro ao remover.');
}
});
}
async onCadastrarLinha() {
if (!this.canManageLines) {
await this.showToast('Você não tem permissão para cadastrar novos clientes.');
return;
}
this.createMode = 'NEW_CLIENT';
this.resetCreateModel();
this.createOpen = true;
void this.loadCreateReservaLines();
this.cdr.detectChanges();
}
async onAddLineToGroup(clientName: string) {
if (!this.canManageLines) {
await this.showToast('Você não tem permissão para adicionar linhas.');
return;
}
this.createMode = 'NEW_LINE_IN_GROUP';
this.resetCreateModel();
this.createModel.cliente = this.isStockClientName(clientName) ? 'RESERVA' : clientName;
if (this.filterSkil === 'PJ') this.createModel.skil = 'PESSOA JURÍDICA';
else if (this.isReserveContextFilter()) this.createModel.skil = 'RESERVA';
this.syncContaEmpresaSelection(this.createModel);
this.createOpen = true;
void this.loadCreateReservaLines();
this.cdr.detectChanges();
}
private resetCreateModel() {
this.createModel = {
cliente: '',
docType: 'PF',
docNumber: '',
contaEmpresa: '',
reservaLineId: '',
linha: '',
chip: '',
tipoDeChip: '',
usuario: '',
status: '',
planoContrato: '',
conta: '',
vencConta: '',
skil: 'PESSOA FÍSICA',
modalidade: '',
item: 0,
cedente: '',
solicitante: '',
dataBloqueio: '',
dataEntregaOpera: '',
dataEntregaCliente: '',
dtEfetivacaoServico: '',
dtTerminoFidelizacao: '',
franquiaVivo: null,
valorPlanoVivo: null,
gestaoVozDados: null,
skeelo: null,
vivoNewsPlus: null,
vivoTravelMundo: null,
vivoGestaoDispositivo: null,
vivoSync: null,
valorContratoVivo: null,
franquiaLine: null,
franquiaGestao: null,
locacaoAp: null,
valorContratoLine: null,
desconto: null,
lucro: null
};
this.createEntryMode = 'SINGLE';
this.createBatchLines = [];
this.selectedBatchLineUid = null;
this.batchDetailOpen = false;
this.batchMassInputText = '';
this.batchMassSeparatorMode = 'AUTO';
this.batchMassPreview = null;
this.batchExcelPreview = null;
this.batchExcelPreviewLoading = false;
this.createBatchValidationByUid = {};
this.createBatchValidationSummary = { total: 0, valid: 0, invalid: 0, duplicates: 0 };
this.createSaving = false;
}
setCreateEntryMode(mode: CreateEntryMode) {
this.createEntryMode = mode;
if (mode === 'BATCH') {
if (this.createBatchLines.length > 0) {
if (this.selectedBatchLineUid == null) {
this.selectedBatchLineUid = this.createBatchLines[0]?.uid ?? null;
}
this.batchDetailOpen = true;
this.ensureBatchLineDefaults(this.selectedBatchLine);
} else {
this.selectedBatchLineUid = null;
this.batchDetailOpen = false;
}
this.recomputeBatchValidation();
return;
}
this.batchDetailOpen = false;
this.recomputeBatchValidation();
}
addBatchLine(seed?: Partial<CreateBatchLineDraft>) {
const templateSource = this.selectedBatchLine ?? this.createBatchLines[this.createBatchLines.length - 1] ?? this.createModel;
const row = this.createBatchDraftFromSource(templateSource, seed, {
keepLinha: false,
keepChip: false,
copyDetails: false
});
this.createBatchLines = [...this.createBatchLines, row];
this.selectedBatchLineUid = row.uid;
this.recomputeBatchValidation();
}
addBatchLines(count: number) {
const total = Math.max(1, Math.min(200, Math.floor(Number(count) || 1)));
for (let i = 0; i < total; i++) this.addBatchLine();
}
removeBatchLine(uid: number) {
this.createBatchLines = this.createBatchLines.filter((x) => x.uid !== uid);
if (this.selectedBatchLineUid === uid) {
this.selectedBatchLineUid = this.createBatchLines[this.createBatchLines.length - 1]?.uid ?? null;
}
if (this.createBatchLines.length === 0) this.batchDetailOpen = false;
this.recomputeBatchValidation();
}
clearBatchLines() {
this.createBatchLines = [];
this.selectedBatchLineUid = null;
this.batchDetailOpen = false;
this.recomputeBatchValidation();
}
onBatchMassInputChange() {
this.batchMassPreview = null;
}
previewBatchMassInput() {
this.batchMassPreview = buildBatchMassPreview(this.batchMassInputText, {
separatorMode: this.batchMassSeparatorMode,
defaults: this.getBatchMassDefaults(),
detectHeader: true
});
}
clearBatchMassInput() {
this.batchMassInputText = '';
this.batchMassPreview = null;
}
useBatchMassExample() {
this.batchMassInputText = buildBatchMassExampleText(this.batchMassSeparatorMode, true);
this.batchMassPreview = null;
}
useBatchMassHeaderTemplate() {
this.batchMassInputText = buildBatchMassHeaderLine(this.batchMassSeparatorMode);
this.batchMassPreview = null;
}
async applyBatchMassInput(mode: BatchMassApplyMode) {
if (!this.batchMassInputText.trim()) {
await this.showToast('Cole ou digite as linhas no campo de entrada em massa.');
return;
}
this.previewBatchMassInput();
const preview = this.batchMassPreview;
if (!preview || preview.recognizedRows <= 0) {
await this.showToast(preview?.parseErrors[0] ?? 'Nenhuma linha reconhecida na entrada em massa.');
return;
}
const parsedRows = preview.rows.map((previewRow) =>
this.createBatchDraftFromSource(
this.createModel,
{
...previewRow.data
} as Partial<CreateBatchLineDraft>,
{ keepLinha: true, keepChip: true, copyDetails: false }
)
);
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) carregada(s) (substituindo o lote atual).`
: `${parsedRows.length} linha(s) adicionada(s) ao lote.`
);
}
selectBatchLine(uid: number) {
this.selectedBatchLineUid = uid;
}
openBatchLineDetails(uid: number) {
this.selectBatchLine(uid);
this.batchDetailOpen = true;
this.ensureBatchLineDefaults(this.selectedBatchLine);
this.recomputeBatchValidation();
}
closeBatchLineDetails() {
this.batchDetailOpen = false;
}
selectPreviousBatchLine() {
const idx = this.selectedBatchLineIndex;
if (idx <= 0) return;
const prev = this.createBatchLines[idx - 1];
if (!prev) return;
this.openBatchLineDetails(prev.uid);
}
selectNextBatchLine() {
const idx = this.selectedBatchLineIndex;
if (idx < 0 || idx >= this.createBatchLines.length - 1) return;
const next = this.createBatchLines[idx + 1];
if (!next) return;
this.openBatchLineDetails(next.uid);
}
duplicateLastBatchLine() {
const source = this.createBatchLines[this.createBatchLines.length - 1];
if (!source) return;
this.duplicateBatchLine(source.uid);
}
duplicateSelectedBatchLine() {
if (this.selectedBatchLineUid == null) return;
this.duplicateBatchLine(this.selectedBatchLineUid);
}
duplicateBatchLine(uid: number) {
const idx = this.createBatchLines.findIndex((x) => x.uid === uid);
if (idx < 0) return;
const source = this.createBatchLines[idx];
const clone = this.createBatchDraftFromSource(source, undefined, {
keepLinha: true,
keepChip: true,
copyDetails: true
});
const next = [...this.createBatchLines];
next.splice(idx + 1, 0, clone);
this.createBatchLines = next;
this.selectedBatchLineUid = clone.uid;
this.recomputeBatchValidation();
}
async removeInvalidBatchLines() {
if (this.createBatchLines.length === 0) return;
const before = this.createBatchLines.length;
this.createBatchLines = this.createBatchLines.filter((row) => (this.getBatchValidation(row.uid)?.errors.length ?? 0) === 0);
const removed = before - this.createBatchLines.length;
if (removed <= 0) return;
if (this.selectedBatchLineUid != null && !this.createBatchLines.some((x) => x.uid === this.selectedBatchLineUid)) {
this.selectedBatchLineUid = this.createBatchLines[this.createBatchLines.length - 1]?.uid ?? null;
}
this.recomputeBatchValidation();
await this.showToast(`${removed} linha(s) inválida(s) removida(s) do lote.`);
}
onBatchLineDraftChange() {
this.recomputeBatchValidation();
}
onBatchLineFieldChange(uid: number) {
this.selectBatchLine(uid);
this.onBatchLineDraftChange();
}
onBatchLineDetailsChange() {
this.onBatchLineDraftChange();
}
applySelectedBatchLineDetailsToAll() {
const source = this.selectedBatchLine;
if (!source) return;
const sourceData = this.getBatchLineDataWithoutInternal(source);
this.createBatchLines = this.createBatchLines.map((row) => {
if (row.uid === source.uid) return row;
return {
...row,
...sourceData,
uid: row.uid,
linha: row.linha,
chip: row.chip
};
});
this.recomputeBatchValidation();
}
trackBatchLine(_index: number, row: CreateBatchLineDraft): number {
return row.uid;
}
isBatchLineSelected(uid: number): boolean {
return this.selectedBatchLineUid === uid;
}
getBatchValidation(uid: number): BatchLineValidation | null {
return this.createBatchValidationByUid[uid] ?? null;
}
getBatchLineErrors(uid: number): string[] {
return this.createBatchValidationByUid[uid]?.errors ?? [];
}
hasBatchLineError(uid: number): boolean {
return (this.createBatchValidationByUid[uid]?.errors.length ?? 0) > 0;
}
hasBatchFieldError(uid: number, field: 'linha' | 'chip'): boolean {
const errors = this.createBatchValidationByUid[uid]?.errors ?? [];
if (field === 'linha') {
return errors.some((e) => e.toLowerCase().includes('linha'));
}
return errors.some((e) => e.toLowerCase().includes('chip'));
}
hasBatchDetailError(uid: number): boolean {
const errors = this.createBatchValidationByUid[uid]?.errors ?? [];
const detailKeywords = ['empresa', 'conta', 'plano', 'status', 'efetivação', 'fidelização', 'detalhes'];
return errors.some((e) => detailKeywords.some((k) => e.toLowerCase().includes(k)));
}
hasBatchRequiredFieldError(uid: number, fieldKey: string): boolean {
const errors = this.createBatchValidationByUid[uid]?.errors ?? [];
const key = fieldKey.toLowerCase();
if (key === 'conta') {
return errors.some((e) => e.toLowerCase().startsWith('conta obrigatória'));
}
if (key === 'empresa') {
return errors.some((e) => e.toLowerCase().startsWith('empresa (conta) obrigatória'));
}
return errors.some((e) => e.toLowerCase().includes(key));
}
getContaEmpresaOptionsForBatchLine(row: any): string[] {
return this.mergeOption(row?.contaEmpresa, this.contaEmpresaOptions);
}
getContaOptionsForBatchLine(row: any): string[] {
const empresaSelecionada = (row?.contaEmpresa ?? '').toString().trim();
const baseOptions = empresaSelecionada ? this.getContasByEmpresa(empresaSelecionada) : this.getAllContas();
return this.mergeOption(row?.conta, baseOptions);
}
getPlanOptionsForBatchLine(row: any): string[] {
return this.mergeOption(row?.planoContrato, this.planOptions);
}
getStatusOptionsForBatchLine(row: any): string[] {
return this.mergeOption(row?.status, this.statusOptions);
}
getSkilOptionsForBatchLine(row: any): string[] {
return this.mergeOption(row?.skil, this.skilOptions);
}
onBatchContaEmpresaChange(row: any) {
if (!row) return;
const contas = this.getContasByEmpresa(row.contaEmpresa);
const selectedConta = (row.conta ?? '').toString().trim();
if (selectedConta) {
const hasMatch = contas.some((c) => this.sameConta(c, selectedConta));
if (!hasMatch) row.conta = '';
}
this.onBatchLineDetailsChange();
}
onBatchPlanoChange(row: any) {
if (!row) return;
const plan = (row.planoContrato ?? '').toString().trim();
if (!plan) {
this.onBatchLineDetailsChange();
return;
}
const suggestion = this.planAutoFill.suggest(plan);
if (suggestion) {
if (suggestion.franquiaGb != null) {
row.franquiaVivo = suggestion.franquiaGb;
if (row.franquiaLine === null || row.franquiaLine === undefined || row.franquiaLine === '') {
row.franquiaLine = suggestion.franquiaGb;
}
}
if (suggestion.valorPlano != null) row.valorPlanoVivo = suggestion.valorPlano;
}
this.calculateFinancials(row);
this.onBatchLineDetailsChange();
}
onBatchFinancialChange(row: any) {
if (!row) return;
this.calculateFinancials(row);
this.onBatchLineDetailsChange();
}
private getBatchMassDefaults() {
return {
usuario: (this.createModel?.usuario ?? '').toString().trim(),
tipoDeChip: (this.createModel?.tipoDeChip ?? '').toString().trim(),
planoContrato: (this.createModel?.planoContrato ?? '').toString().trim(),
status: (this.createModel?.status ?? '').toString().trim(),
contaEmpresa: (this.createModel?.contaEmpresa ?? '').toString().trim(),
conta: (this.createModel?.conta ?? '').toString().trim(),
dtEfetivacaoServico: (this.createModel?.dtEfetivacaoServico ?? '').toString().trim(),
dtTerminoFidelizacao: (this.createModel?.dtTerminoFidelizacao ?? '').toString().trim()
};
}
private createBatchDraftFromSource(
source: any,
seed?: Partial<CreateBatchLineDraft>,
opts?: { keepLinha?: boolean; keepChip?: boolean; copyDetails?: boolean }
): CreateBatchLineDraft {
const keepLinha = !!opts?.keepLinha;
const keepChip = !!opts?.keepChip;
const copyDetails = opts?.copyDetails ?? true;
const baseSource = source ?? this.createModel ?? {};
const raw = this.getBatchLineDataWithoutInternal(baseSource);
this.createBatchUidSeed += 1;
const row: CreateBatchLineDraft = {
...raw,
...(seed ? this.getBatchLineDataWithoutInternal(seed) : {}),
uid: this.createBatchUidSeed,
item: 0,
linha: keepLinha ? (seed?.linha ?? raw.linha ?? '') : (seed?.linha ?? ''),
chip: keepChip ? (seed?.chip ?? raw.chip ?? '') : (seed?.chip ?? ''),
usuario: (seed?.usuario ?? raw.usuario ?? '').toString(),
tipoDeChip: (seed?.tipoDeChip ?? raw.tipoDeChip ?? '').toString()
};
if (!copyDetails) {
const clearScalarFields = [
'contaEmpresa',
'conta',
'status',
'planoContrato',
'vencConta',
'modalidade',
'cedente',
'solicitante',
'dataBloqueio',
'dataEntregaOpera',
'dataEntregaCliente',
'dtEfetivacaoServico',
'dtTerminoFidelizacao'
];
const clearNumericFields = [
'franquiaVivo',
'valorPlanoVivo',
'gestaoVozDados',
'skeelo',
'vivoNewsPlus',
'vivoTravelMundo',
'vivoGestaoDispositivo',
'vivoSync',
'valorContratoVivo',
'franquiaLine',
'franquiaGestao',
'locacaoAp',
'valorContratoLine',
'desconto',
'lucro'
];
const mutableRow = row as Record<string, any>;
clearScalarFields.forEach((key) => (mutableRow[key] = ''));
clearNumericFields.forEach((key) => (mutableRow[key] = null));
if (seed) {
Object.assign(row, this.getBatchLineDataWithoutInternal(seed));
}
}
this.ensureBatchLineDefaults(row);
return row;
}
private getBatchLineDataWithoutInternal(source: any): any {
if (!source) return {};
const { uid, ...rest } = source;
return { ...rest };
}
private ensureBatchLineDefaults(row: any) {
if (!row) return;
if (!('item' in row)) row.item = 0;
if (!('skil' in row) || row.skil === null || row.skil === undefined || row.skil === '') {
row.skil = this.createMode === 'NEW_LINE_IN_GROUP' ? (this.createModel?.skil ?? 'PESSOA FÍSICA') : (this.createModel?.skil ?? 'PESSOA FÍSICA');
}
const scalarDefaults: Array<[string, any]> = [
['linha', ''],
['chip', ''],
['tipoDeChip', ''],
['usuario', ''],
['contaEmpresa', ''],
['conta', ''],
['status', ''],
['planoContrato', ''],
['vencConta', ''],
['modalidade', ''],
['cedente', ''],
['solicitante', ''],
['dataBloqueio', ''],
['dataEntregaOpera', ''],
['dataEntregaCliente', ''],
['dtEfetivacaoServico', ''],
['dtTerminoFidelizacao', '']
];
const numericDefaults: Array<[string, any]> = [
['franquiaVivo', null],
['valorPlanoVivo', null],
['gestaoVozDados', null],
['skeelo', null],
['vivoNewsPlus', null],
['vivoTravelMundo', null],
['vivoGestaoDispositivo', null],
['vivoSync', null],
['valorContratoVivo', null],
['franquiaLine', null],
['franquiaGestao', null],
['locacaoAp', null],
['valorContratoLine', null],
['desconto', null],
['lucro', null]
];
scalarDefaults.forEach(([k, v]) => {
if (!(k in row) || row[k] === null || row[k] === undefined) row[k] = v;
});
numericDefaults.forEach(([k, v]) => {
if (!(k in row)) row[k] = v;
});
if (!(row.contaEmpresa ?? '').toString().trim() && (row.conta ?? '').toString().trim()) {
row.contaEmpresa = this.findEmpresaByConta(row.conta);
}
row.cliente = (this.createModel?.cliente ?? row.cliente ?? '').toString();
}
private recomputeBatchValidation() {
const byUid: Record<number, BatchLineValidation> = {};
const linhaCounts = new Map<string, number>();
const chipCounts = new Map<string, number>();
this.createBatchLines.forEach((row) => {
const linhaDigits = (row?.linha ?? '').toString().replace(/\D/g, '');
if (!linhaDigits) return;
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 invalid = 0;
let duplicates = 0;
this.createBatchLines.forEach((row, index) => {
const linhaRaw = (row?.linha ?? '').toString().trim();
const chipRaw = (row?.chip ?? '').toString().trim();
const linhaDigits = linhaRaw.replace(/\D/g, '');
const chipDigits = chipRaw.replace(/\D/g, '');
const errors: string[] = [];
if (!linhaRaw) errors.push('Linha obrigatória.');
else if (!linhaDigits) errors.push('Número de linha inválido.');
if (!chipRaw) errors.push('Chip (ICCID) obrigatório.');
else if (!chipDigits) errors.push('Chip (ICCID) inválido.');
const contaEmpresa = (row?.['contaEmpresa'] ?? '').toString().trim();
const conta = (row?.['conta'] ?? '').toString().trim();
const status = (row?.['status'] ?? '').toString().trim();
const plano = (row?.['planoContrato'] ?? '').toString().trim();
const dtEfet = (row?.['dtEfetivacaoServico'] ?? '').toString().trim();
const dtFidel = (row?.['dtTerminoFidelizacao'] ?? '').toString().trim();
if (!contaEmpresa) errors.push('Empresa (Conta) obrigatória.');
if (!conta) errors.push('Conta obrigatória.');
if (!status) errors.push('Status obrigatório.');
if (!plano) errors.push('Plano Contrato obrigatório.');
if (!dtEfet) errors.push('Dt. Efetivação Serviço obrigatória.');
if (!dtFidel) errors.push('Dt. Término Fidelização obrigatória.');
const isLinhaDuplicate = !!linhaDigits && (linhaCounts.get(linhaDigits) ?? 0) > 1;
const isChipDuplicate = !!chipDigits && (chipCounts.get(chipDigits) ?? 0) > 1;
if (isLinhaDuplicate) {
errors.push('Linha duplicada no lote.');
}
if (isChipDuplicate) {
errors.push('Chip (ICCID) duplicado no lote.');
}
if (isLinhaDuplicate || isChipDuplicate) {
duplicates++;
}
const hasDetailPending = errors.some((e) =>
['empresa', 'conta', 'status', 'plano', 'efetivação', 'fidelização'].some((k) =>
e.toLowerCase().includes(k)
)
);
if (hasDetailPending && !errors.some((e) => e.toLowerCase().includes('pendências nos detalhes'))) {
errors.unshift('Pendências nos detalhes da linha.');
}
if (errors.length > 0) invalid++;
else valid++;
byUid[row.uid] = {
uid: row.uid,
index,
linhaDigits,
errors
};
});
this.createBatchValidationByUid = byUid;
this.createBatchValidationSummary = {
total: this.createBatchLines.length,
valid,
invalid,
duplicates
};
}
private validateCreateClientFields(): string | null {
if (this.createMode !== 'NEW_CLIENT') return null;
if (!this.createModel.cliente) {
return this.createModel.docType === 'PF' ? 'Informe o Nome Completo.' : 'Informe a Razão Social.';
}
if (!this.createModel.docNumber) {
return `O ${this.createModel.docType === 'PF' ? 'CPF' : 'CNPJ'} é obrigatório.`;
}
return null;
}
private validateCreateCommonFields(opts?: { requireLinha?: boolean; requireChip?: boolean }): string | null {
const requireLinha = opts?.requireLinha ?? true;
const requireChip = opts?.requireChip ?? true;
if (!this.createModel.contaEmpresa) return 'Selecione a Empresa (Conta).';
if (!this.createModel.conta) return 'Selecione uma Conta.';
if (requireLinha && !this.createModel.linha) return 'O número da Linha é obrigatório.';
if (requireChip && !this.createModel.chip) return 'O Chip (ICCID) é obrigatório.';
if (!this.createModel.status) return 'Selecione um Status.';
if (!this.createModel.planoContrato) return 'Selecione um Plano.';
if (!this.createModel.dtEfetivacaoServico) return 'A Dt. Efetivação Serviço é obrigatória.';
if (!this.createModel.dtTerminoFidelizacao) return 'A Dt. Término Fidelização é obrigatória.';
return null;
}
private validateBatchLines(): string | null {
this.recomputeBatchValidation();
if (this.createBatchLines.length === 0) {
return 'Adicione ao menos uma linha no lote.';
}
if (this.createBatchValidationSummary.invalid <= 0) {
return null;
}
for (let i = 0; i < this.createBatchLines.length; i++) {
const row = this.createBatchLines[i];
const errors = this.getBatchLineErrors(row.uid);
if (errors.length <= 0) continue;
return `Linha ${i + 1}: ${errors[0]}`;
}
return 'Existem linhas inválidas no lote.';
}
private buildCreatePayload(model: any): CreateMobileLineRequest {
this.calculateFinancials(model);
const { contaEmpresa: _contaEmpresa, uid: _uid, ...createModelPayload } = model;
return {
...createModelPayload,
item: Number(model.item),
reservaLineId: (model.reservaLineId ?? '').toString().trim() || null,
dataBloqueio: this.dateInputToIso(model.dataBloqueio),
dataEntregaOpera: this.dateInputToIso(model.dataEntregaOpera),
dataEntregaCliente: this.dateInputToIso(model.dataEntregaCliente),
dtEfetivacaoServico: this.dateInputToIso(model.dtEfetivacaoServico),
dtTerminoFidelizacao: this.dateInputToIso(model.dtTerminoFidelizacao),
franquiaVivo: this.toNullableNumber(model.franquiaVivo),
valorPlanoVivo: this.toNullableNumber(model.valorPlanoVivo),
gestaoVozDados: this.toNullableNumber(model.gestaoVozDados),
skeelo: this.toNullableNumber(model.skeelo),
vivoNewsPlus: this.toNullableNumber(model.vivoNewsPlus),
vivoTravelMundo: this.toNullableNumber(model.vivoTravelMundo),
vivoGestaoDispositivo: this.toNullableNumber(model.vivoGestaoDispositivo),
vivoSync: this.toNullableNumber(model.vivoSync),
valorContratoVivo: this.toNullableNumber(model.valorContratoVivo),
franquiaLine: this.toNullableNumber(model.franquiaLine),
franquiaGestao: this.toNullableNumber(model.franquiaGestao),
locacaoAp: this.toNullableNumber(model.locacaoAp),
valorContratoLine: this.toNullableNumber(model.valorContratoLine),
desconto: this.toNullableNumber(model.desconto),
lucro: this.toNullableNumber(model.lucro),
tipoDeChip: (model.tipoDeChip ?? '').toString()
};
}
private buildBatchPayloads(): CreateMobileLineRequest[] {
const clientName = (this.createModel?.cliente ?? '').toString().trim();
return this.createBatchLines.map((row) => {
const lineModel = {
...row,
cliente: clientName || (row?.['cliente'] ?? '').toString(),
linha: (row.linha ?? '').toString(),
chip: (row.chip ?? '').toString(),
usuario: (row.usuario ?? '').toString(),
tipoDeChip: (row.tipoDeChip ?? '').toString(),
reservaLineId: null
};
return this.buildCreatePayload(lineModel);
});
}
private getCreateSuccessMessage(createdCount: number): string {
if (createdCount <= 1) {
return this.createMode === 'NEW_CLIENT' ? 'Sucesso! Cliente cadastrado.' : 'Linha cadastrada com sucesso.';
}
return `Sucesso! ${createdCount} linhas cadastradas no lote.`;
}
private async finalizeCreateSuccess(createdCount: number) {
const targetClient = (this.createModel?.cliente ?? '').toString().trim();
const isNewClientCreated = this.createMode === 'NEW_CLIENT' && !!targetClient;
this.createSaving = false;
this.closeAllModals();
if (isNewClientCreated) {
this.tenantSyncService.notifyTenantsChanged();
}
await this.showToast(this.getCreateSuccessMessage(createdCount));
if (this.createMode === 'NEW_LINE_IN_GROUP' && this.expandedGroup === targetClient) {
this.fetchGroupLines(this.expandedGroup!, this.getActiveExpandedGroupSearchTerm());
this.loadGroups();
this.loadKpis();
} else {
this.refreshData();
}
}
private async handleCreateError(err: HttpErrorResponse, fallbackMessage = 'Erro ao criar registro.') {
this.createSaving = false;
const msg = (err.error as any)?.message || fallbackMessage;
await this.showToast(msg);
}
onContaEmpresaChange(isEdit: boolean) {
const model = isEdit ? this.editModel : this.createModel;
if (!model) return;
const contas = this.getContasByEmpresa(model.contaEmpresa);
const selectedConta = (model.conta ?? '').toString().trim();
if (!selectedConta) return;
const hasMatch = contas.some((c) => this.sameConta(c, selectedConta));
if (!hasMatch) model.conta = '';
}
onContaChange(isEdit: boolean) {
const model = isEdit ? this.editModel : this.createModel;
this.syncContaEmpresaSelection(model);
}
onBatchContaChange(row: any) {
this.syncContaEmpresaSelection(row);
this.onBatchLineDetailsChange();
}
onDocTypeChange() {
this.createModel.docNumber = '';
this.createModel.skil = this.createModel.docType === 'PF' ? 'PESSOA FÍSICA' : 'PESSOA JURÍDICA';
}
onDocInput(event: any) {
let value = event.target.value.replace(/\D/g, '');
if (this.createModel.docType === 'PF') {
if (value.length > 11) value = value.slice(0, 11);
value = value.replace(/(\d{3})(\d)/, '$1.$2');
value = value.replace(/(\d{3})(\d)/, '$1.$2');
value = value.replace(/(\d{3})(\d{1,2})$/, '$1-$2');
} else {
if (value.length > 14) value = value.slice(0, 14);
value = value.replace(/^(\d{2})(\d)/, '$1.$2');
value = value.replace(/^(\d{2})\.(\d{3})(\d)/, '$1.$2.$3');
value = value.replace(/\.(\d{3})(\d)/, '.$1/$2');
value = value.replace(/(\d{4})(\d)/, '$1-$2');
}
this.createModel.docNumber = value;
}
private async loadCreateReservaLines(): Promise<void> {
if (this.loadingCreateReservaLines) return;
this.loadingCreateReservaLines = true;
try {
const pageSize = 500;
let page = 1;
const collected: ReservaLineOption[] = [];
while (true) {
const params = new HttpParams()
.set('page', String(page))
.set('pageSize', String(pageSize))
.set('skil', 'RESERVA');
const response = await firstValueFrom(this.http.get<ApiPagedResult<ApiLineList>>(this.apiBase, { params }));
const items = Array.isArray(response?.items) ? response.items : [];
for (const row of items) {
const id = (row?.id ?? '').toString().trim();
const linha = (row?.linha ?? '').toString().trim();
if (!id || !linha) continue;
collected.push({
value: id,
label: `${row?.item ?? ''}${linha}${(row?.usuario ?? 'SEM USUÁRIO').toString()}`,
linha,
chip: (row?.chip ?? '').toString(),
usuario: (row?.usuario ?? '').toString()
});
}
const total = Number(response?.total ?? 0);
if (!items.length || (total > 0 && collected.length >= total) || items.length < pageSize) break;
page += 1;
}
const seen = new Set<string>();
const unique = collected.filter((opt) => {
if (seen.has(opt.value)) return false;
seen.add(opt.value);
return true;
});
unique.sort((a, b) => a.linha.localeCompare(b.linha, 'pt-BR', { numeric: true, sensitivity: 'base' }));
this.createReservaLineOptions = unique;
this.createReservaLineLookup = new Map(unique.map((opt) => [opt.value, opt]));
} catch {
this.createReservaLineOptions = [];
this.createReservaLineLookup.clear();
await this.showToast('Erro ao carregar linhas da Reserva.');
} finally {
this.loadingCreateReservaLines = false;
}
}
onCreateReservaLineChange() {
const lineId = (this.createModel?.reservaLineId ?? '').toString().trim();
if (!lineId) {
this.createModel.linha = '';
return;
}
const selected = this.createReservaLineLookup.get(lineId);
if (selected) {
this.createModel.linha = selected.linha ?? '';
if (!String(this.createModel.chip ?? '').trim() && selected.chip) {
this.createModel.chip = selected.chip;
}
if (!String(this.createModel.usuario ?? '').trim() && selected.usuario) {
this.createModel.usuario = selected.usuario;
}
}
this.http.get<ApiLineDetail>(`${this.apiBase}/${lineId}`).subscribe({
next: (detail) => {
this.createModel.linha = (detail?.linha ?? this.createModel.linha ?? '').toString();
if (!String(this.createModel.chip ?? '').trim() && detail?.chip) {
this.createModel.chip = detail.chip;
}
if (!String(this.createModel.tipoDeChip ?? '').trim() && detail?.tipoDeChip) {
this.createModel.tipoDeChip = detail.tipoDeChip;
}
if (!String(this.createModel.usuario ?? '').trim() && detail?.usuario) {
this.createModel.usuario = detail.usuario;
}
},
error: () => {
// Mantém dados já carregados da lista.
}
});
}
async saveCreate() {
if (this.isCreateBatchMode) {
await this.saveCreateBatch();
return;
}
await this.saveCreateSingle();
}
private async saveCreateSingle() {
const clientError = this.validateCreateClientFields();
if (clientError) {
await this.showToast(clientError);
return;
}
const commonError = this.validateCreateCommonFields({ requireLinha: true, requireChip: true });
if (commonError) {
await this.showToast(commonError);
return;
}
this.createSaving = true;
const payload = this.buildCreatePayload(this.createModel);
this.http.post<ApiLineDetail>(this.apiBase, payload).subscribe({
next: async () => {
await this.finalizeCreateSuccess(1);
},
error: async (err: HttpErrorResponse) => {
await this.handleCreateError(err);
}
});
}
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 openBatchStatusModal(action: BatchStatusAction) {
if (!this.canManageLines) {
await this.showToast('Você não tem permissão para bloquear/desbloquear em lote.');
return;
}
if (this.batchStatusSelectionCount <= 0) {
await this.showToast('Selecione ao menos uma linha para processar.');
return;
}
this.batchStatusAction = action;
this.batchStatusSaving = false;
this.batchStatusLastResult = null;
this.batchStatusUsuario = '';
if (action === 'BLOCK') {
const current = (this.batchStatusType ?? '').toString().trim();
const options = this.blockedStatusOptions;
if (!current || !options.some((x) => x === current)) {
this.batchStatusType = options[0] ?? '';
}
} else {
this.batchStatusType = '';
}
this.batchStatusOpen = true;
this.cdr.detectChanges();
}
async submitBatchStatusUpdate() {
if (this.batchStatusSaving) return;
if (!this.canSubmitBatchStatusModal) return;
const payload = this.buildBatchStatusPayload();
this.batchStatusSaving = true;
this.http.post<BatchLineStatusUpdateResultDto>(`${this.apiBase}/batch-status-update`, payload).subscribe({
next: async (res) => {
this.batchStatusSaving = false;
this.batchStatusLastResult = res;
const ok = Number(res?.updated ?? 0) || 0;
const failed = Number(res?.failed ?? 0) || 0;
this.batchStatusOpen = false;
this.clearReservaSelection();
this.batchStatusUsuario = '';
await this.showToast(
failed > 0
? `${this.batchStatusActionLabel} em lote concluído com pendências: ${ok} linha(s) processada(s), ${failed} falha(s).`
: `${this.batchStatusActionLabel} em lote concluído: ${ok} linha(s) processada(s).`
);
if (this.expandedGroup) {
this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm());
}
this.loadGroups();
this.loadKpis();
},
error: async (err: HttpErrorResponse) => {
this.batchStatusSaving = false;
const msg = (err.error as any)?.message || 'Erro ao processar bloqueio/desbloqueio em lote.';
await this.showToast(msg);
}
});
}
private buildBatchStatusPayload(): BatchLineStatusUpdateRequestDto {
const clients = this.searchResolvedClient
? [this.searchResolvedClient]
: [...this.selectedClients];
const normalizedClients = clients
.map((x) => (x ?? '').toString().trim())
.filter((x) => !!x);
const userFilter = (this.batchStatusUsuario ?? '').toString().trim();
return {
action: this.batchStatusAction === 'BLOCK' ? 'block' : 'unblock',
blockStatus: this.batchStatusAction === 'BLOCK' ? (this.batchStatusType || null) : null,
applyToAllFiltered: false,
lineIds: [...this.reservaSelectedLineIds],
search: (this.searchTerm ?? '').toString().trim() || null,
skil: this.resolveFilterSkilForApi(),
clients: normalizedClients,
additionalMode: this.resolveAdditionalModeForApi(),
additionalServices: this.selectedAdditionalServices.length > 0 ? this.selectedAdditionalServices.join(',') : null,
usuario: userFilter || null
};
}
private resolveFilterSkilForApi(): string | null {
if (this.filterSkil === 'PF') return 'PESSOA FÍSICA';
if (this.filterSkil === 'PJ') return 'PESSOA JURÍDICA';
if (this.isReserveContextFilter()) return 'RESERVA';
return null;
}
private resolveAdditionalModeForApi(): string | null {
if (this.additionalMode === 'WITH') return 'with';
if (this.additionalMode === 'WITHOUT') return 'without';
return null;
}
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 ||
clienteDestino.localeCompare('ESTOQUE', 'pt-BR', { sensitivity: 'base' }) === 0
) {
await this.showToast('O cliente de destino não pode ser RESERVA/ESTOQUE.');
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) {
this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm());
}
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) {
this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm());
}
this.loadGroups();
this.loadKpis();
return;
}
const firstError = res?.items?.find((x) => !x.success)?.message || 'Nenhuma linha foi enviada para a Reserva.';
await this.showToast(firstError);
},
error: async (err: HttpErrorResponse) => {
this.moveToReservaSaving = false;
const msg = (err.error as any)?.message || 'Erro ao enviar linhas para a Reserva.';
await this.showToast(msg);
}
});
}
async onDownloadBatchExcelTemplate() {
if (this.batchExcelTemplateDownloading) return;
this.batchExcelTemplateDownloading = true;
const params = new HttpParams().set('_', `${Date.now()}`);
this.http.get(`${this.templatesApiBase}/planilha-geral`, { params, observe: 'response', responseType: 'blob' }).subscribe({
next: async (res) => {
this.batchExcelTemplateDownloading = false;
const blob = res.body;
if (!blob) {
await this.showToast('Não foi possível baixar o modelo da planilha.');
return;
}
const disposition = res.headers.get('content-disposition') || '';
const fileName = this.extractDownloadFileName(disposition) || 'MODELO_GERAL_LINEGESTAO.xlsx';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
},
error: async (err: HttpErrorResponse) => {
this.batchExcelTemplateDownloading = false;
const msg = (err.error as any)?.message || 'Erro ao baixar o modelo da planilha.';
await this.showToast(msg);
}
});
}
private extractDownloadFileName(contentDisposition: string): string | null {
const raw = (contentDisposition ?? '').trim();
if (!raw) return null;
const utf8Match = raw.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1].trim().replace(/^"(.*)"$/, '$1'));
} catch {
return utf8Match[1].trim().replace(/^"(.*)"$/, '$1');
}
}
const simpleMatch = raw.match(/filename\s*=\s*([^;]+)/i);
if (!simpleMatch?.[1]) return null;
return simpleMatch[1].trim().replace(/^"(.*)"$/, '$1');
}
async onImportBatchExcel() {
if (this.createSaving) return;
if (!this.isCreateBatchMode) {
await this.showToast('Ative o modo Lote de Linhas para importar a planilha.');
return;
}
if (!this.batchExcelInput?.nativeElement) return;
this.batchExcelInput.nativeElement.value = '';
this.batchExcelInput.nativeElement.click();
}
onBatchExcelSelected(ev: Event) {
const file = (ev.target as HTMLInputElement).files?.[0];
if (!file) return;
const form = new FormData();
form.append('file', file);
this.batchExcelPreviewLoading = true;
this.batchExcelPreview = null;
this.http.post<BatchExcelPreviewResultDto>(`${this.apiBase}/batch/import-preview`, form).subscribe({
next: (preview) => {
this.batchExcelPreviewLoading = false;
this.batchExcelPreview = preview;
this.cdr.detectChanges();
},
error: async (err: HttpErrorResponse) => {
this.batchExcelPreviewLoading = false;
this.batchExcelPreview = null;
const msg = (err.error as any)?.message || 'Falha ao pré-visualizar a planilha do lote.';
await this.showToast(msg);
}
});
}
clearBatchExcelPreview() {
this.batchExcelPreview = null;
this.batchExcelPreviewLoading = false;
}
private mapBatchExcelPreviewRowToSeed(row: BatchExcelPreviewRowDto): Partial<CreateBatchLineDraft> {
const data = row?.data ?? {};
return {
...data,
item: 0,
linha: (data.linha ?? '').toString(),
chip: (data.chip ?? '').toString(),
usuario: (data.usuario ?? '').toString(),
tipoDeChip: (data.tipoDeChip ?? '').toString(),
dataBloqueio: this.isoToDateInput(data.dataBloqueio as any),
dataEntregaOpera: this.isoToDateInput(data.dataEntregaOpera as any),
dataEntregaCliente: this.isoToDateInput(data.dataEntregaCliente as any),
dtEfetivacaoServico: this.isoToDateInput(data.dtEfetivacaoServico as any),
dtTerminoFidelizacao: this.isoToDateInput(data.dtTerminoFidelizacao as any)
};
}
async applyBatchExcelPreview(mode: BatchMassApplyMode) {
const preview = this.batchExcelPreview;
if (!preview) {
await this.showToast('Importe uma planilha para gerar a pré-visualização.');
return;
}
if ((preview.headerErrors?.length ?? 0) > 0) {
await this.showToast(preview.headerErrors[0]?.message || 'Corrija os erros de cabeçalho antes de aplicar.');
return;
}
const validRows = (preview.rows ?? []).filter((row) => row.valid);
if (validRows.length <= 0) {
await this.showToast('Nenhuma linha válida encontrada na planilha para carregar no lote.');
return;
}
const parsedRows = validRows.map((row) =>
this.createBatchDraftFromSource(
this.createModel,
this.mapBatchExcelPreviewRowToSeed(row),
{ keepLinha: true, keepChip: true, copyDetails: true }
)
);
this.createBatchLines = mergeMassRows(this.createBatchLines, parsedRows, mode);
this.selectedBatchLineUid = parsedRows[parsedRows.length - 1]?.uid ?? this.selectedBatchLineUid;
this.batchDetailOpen = this.createBatchLines.length > 0;
this.recomputeBatchValidation();
await this.showToast(
mode === 'REPLACE'
? `${parsedRows.length} linha(s) válida(s) carregada(s) da planilha (substituindo o lote atual).`
: `${parsedRows.length} linha(s) válida(s) adicionada(s) ao lote pela planilha.`
);
}
private async saveCreateBatch() {
const clientError = this.validateCreateClientFields();
if (clientError) {
await this.showToast(clientError);
return;
}
const batchError = this.validateBatchLines();
if (batchError) {
await this.showToast(batchError);
return;
}
this.createSaving = true;
const payload: CreateMobileLinesBatchRequest = {
lines: this.buildBatchPayloads()
};
this.http.post<CreateMobileLinesBatchResponse>(`${this.apiBase}/batch`, payload).subscribe({
next: async (res) => {
const createdCount = Number(res?.created ?? payload.lines.length) || payload.lines.length;
await this.finalizeCreateSuccess(createdCount);
},
error: async (err: HttpErrorResponse) => {
if (err.status === 405) {
await this.showToast(
'A API em execução não aceita POST em /api/lines/batch (405). Reinicie/atualize o backend para a versão com o endpoint de lote.'
);
this.createSaving = false;
return;
}
await this.handleCreateError(err, 'Erro ao criar lote de linhas.');
}
});
}
private async showToast(message: string) {
if (!isPlatformBrowser(this.platformId)) return;
this.toastMessage = message;
this.cdr.detectChanges();
if (!this.successToast?.nativeElement) return;
try {
const bs = await import('bootstrap');
const toastInstance = bs.Toast.getOrCreateInstance(this.successToast.nativeElement, {
autohide: true,
delay: 3000
});
toastInstance.show();
} catch (error) {
console.error(error);
}
}
private matchesMveIssueFilter(issue: MveAuditIssue): boolean {
switch (this.mveAuditFilter) {
case 'STATUS':
return this.issueHasStatusDifference(issue);
case 'DATA':
return this.issueHasDataDifference(issue);
case 'ONLY_IN_SYSTEM':
return issue.issueType === 'ONLY_IN_SYSTEM';
case 'ONLY_IN_REPORT':
return issue.issueType === 'ONLY_IN_REPORT';
case 'DUPLICATES':
return issue.issueType === 'DUPLICATE_REPORT' || issue.issueType === 'DUPLICATE_SYSTEM';
case 'INVALID':
return issue.issueType === 'INVALID_ROW';
case 'UNKNOWN':
return issue.issueType === 'UNKNOWN_STATUS';
default:
return true;
}
}
private issueHasStatusDifference(issue: MveAuditIssue): boolean {
return (issue.differences ?? []).some((difference) => difference.fieldKey === 'status');
}
private issueHasDataDifference(issue: MveAuditIssue): boolean {
return (issue.differences ?? []).some(
(difference) => difference.fieldKey !== 'status' && difference.syncable
);
}
private normalizeMveSearchTerm(value: unknown): string {
return (value ?? '')
.toString()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim();
}
private escapeCsvValue(value: unknown): string {
const text = (value ?? '').toString().replace(/"/g, '""');
return `"${text}"`;
}
private downloadBlob(blob: Blob, fileName: string) {
if (!isPlatformBrowser(this.platformId)) return;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
private extractHttpMessage(error: unknown, fallbackMessage: string): string {
const httpError = error as HttpErrorResponse | null;
return (
(httpError?.error as { message?: string } | null)?.message ||
httpError?.message ||
fallbackMessage
);
}
formatMoney(v: any): string {
if (v == null || Number.isNaN(v)) return '-';
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v);
}
formatNumber(v: any): string {
if (v == null || Number.isNaN(v)) return '-';
return new Intl.NumberFormat('pt-BR').format(v);
}
formatFranquia(v: any): string {
if (v == null || Number.isNaN(v)) return '-';
return `${new Intl.NumberFormat('pt-BR').format(v)} GB`;
}
formatDateBr(iso: any): string {
if (!iso) return '-';
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '-' : new Intl.DateTimeFormat('pt-BR').format(d);
}
statusClass(s: any): string {
const n = (s ?? '').toString().toLowerCase();
if (n.includes('bloq') || n.includes('perda') || n.includes('roubo')) return 'is-blocked';
if (n.includes('ativo')) return 'is-active';
return '';
}
statusLabel(s: any): string {
const v = (s ?? '').toString().trim();
return v || '-';
}
private toEditModel(d: ApiLineDetail): any {
return {
...d,
item: d.item ?? 0,
centroDeCustos: d.centroDeCustos ?? '',
setorId: d.setorId ?? null,
setorNome: d.setorNome ?? '',
aparelhoId: d.aparelhoId ?? null,
aparelhoNome: d.aparelhoNome ?? '',
aparelhoCor: d.aparelhoCor ?? '',
aparelhoImei: d.aparelhoImei ?? '',
aparelhoNotaFiscalTemArquivo: !!d.aparelhoNotaFiscalTemArquivo,
aparelhoReciboTemArquivo: !!d.aparelhoReciboTemArquivo,
dataBloqueio: this.isoToDateInput(d.dataBloqueio),
dataEntregaOpera: this.isoToDateInput(d.dataEntregaOpera),
dataEntregaCliente: this.isoToDateInput(d.dataEntregaCliente),
dtEfetivacaoServico: this.isoToDateInput(d.dtEfetivacaoServico),
dtTerminoFidelizacao: this.isoToDateInput(d.dtTerminoFidelizacao),
franquiaVivo: d.franquiaVivo ?? null,
valorPlanoVivo: d.valorPlanoVivo ?? null,
gestaoVozDados: d.gestaoVozDados ?? null,
skeelo: d.skeelo ?? null,
vivoNewsPlus: d.vivoNewsPlus ?? null,
vivoTravelMundo: d.vivoTravelMundo ?? null,
vivoGestaoDispositivo: d.vivoGestaoDispositivo ?? null,
vivoSync: d.vivoSync ?? null,
valorContratoVivo: d.valorContratoVivo ?? null,
franquiaLine: d.franquiaLine ?? null,
franquiaLineSolicitada: d.franquiaLine ?? null,
franquiaGestao: d.franquiaGestao ?? null,
locacaoAp: d.locacaoAp ?? null,
valorContratoLine: d.valorContratoLine ?? null,
desconto: d.desconto ?? null,
lucro: d.lucro ?? null,
tipoDeChip: d.tipoDeChip ?? '',
contaEmpresa: this.findEmpresaByConta(d.conta)
};
}
private isoToDateInput(iso?: string | null): string {
if (!iso) return '';
const s = iso.toString();
// se já vier YYYY-MM-DD, mantém
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
// se vier ISO completo, pega só a data
if (s.includes('T')) return s.slice(0, 10);
return s;
}
private dateInputToIso(v: any): string | null {
const s = (v ?? '').toString().trim();
if (!s) return null;
// Mantém YYYY-MM-DD (excelente para DateOnly no backend)
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
return s;
}
private toInt(v: any): number {
if (v === null || v === undefined || v === '') return 0;
const n = parseInt(v.toString(), 10);
return Number.isNaN(n) ? 0 : n;
}
private toNullableNumber(v: any): number | null {
if (v === null || v === undefined || v === '') return null;
if (typeof v === 'number') return Number.isNaN(v) ? null : v;
const n = parseFloat(v.toString().replace(',', '.'));
return Number.isNaN(n) ? null : n;
}
private nullableNumberEquals(a: number | null, b: number | null): boolean {
if (a === null || b === null) return a === b;
return Math.abs(a - b) < 0.000001;
}
private getAnyField(row: unknown, keys: string[]): unknown {
const source = row as Record<string, unknown>;
for (const key of keys) {
if (source && source[key] !== undefined && source[key] !== null && source[key] !== '') {
return source[key];
}
}
return null;
}
private mergeOption(current: any, list: string[]): string[] {
const v = (current ?? '').toString().trim();
if (!v) return list;
return list.includes(v) ? list : [v, ...list];
}
private mergeOptionList(base: string[], extra: string[]): string[] {
const result: string[] = [...base];
const seen = new Set(base.map((x) => x.trim()).filter(Boolean));
extra.forEach((raw) => {
const v = (raw ?? '').toString().trim();
if (!v || seen.has(v)) return;
seen.add(v);
result.push(v);
});
return result;
}
private normalizeAccountCompanies(data: AccountCompanyOption[] | null | undefined): AccountCompanyOption[] {
if (!Array.isArray(data)) return [];
const result: AccountCompanyOption[] = [];
data.forEach((item) => {
const empresa = (item?.empresa ?? '').toString().trim();
if (!empresa) return;
const contas = this.mergeOptionList([], (item?.contas ?? []).map((x) => (x ?? '').toString().trim()));
result.push({ empresa, contas });
});
return result;
}
private getAllContas(): string[] {
const all = this.accountCompanies.flatMap((x) => x.contas ?? []);
return this.mergeOptionList([], all);
}
private getContasByEmpresa(empresa: any): string[] {
const target = (empresa ?? '').toString().trim();
if (!target) return [];
const found = this.accountCompanies.find((x) =>
x.empresa.localeCompare(target, 'pt-BR', { sensitivity: 'base' }) === 0
);
return found ? [...found.contas] : [];
}
private getContaEmpresaOptionsByOperadora(mode: OperadoraFilterMode): string[] {
const empresas = this.mergeOptionList([], this.accountCompanies.map((group) => group?.empresa ?? ''))
.filter((empresa) => !!(empresa ?? '').toString().trim());
const filtered = mode === 'ALL'
? empresas
: empresas.filter((empresa) => {
const operadora = resolveOperadoraContext({
empresaConta: empresa,
accountCompanies: this.accountCompanies,
}).operadora;
return operadora === mode;
});
return filtered.sort((a, b) => a.localeCompare(b, 'pt-BR', { sensitivity: 'base' }));
}
private syncContaEmpresaFilterByOperadora(): void {
const selected = this.filterContaEmpresa.trim();
if (!selected) return;
const available = this.getContaEmpresaOptionsByOperadora(this.filterOperadora);
const normalizedSelected = this.normalizeFilterToken(selected);
const hasSelected = available.some((empresa) => this.normalizeFilterToken(empresa) === normalizedSelected);
if (!hasSelected) {
this.filterContaEmpresa = '';
}
}
private findEmpresaByConta(conta: any): string {
return resolveEmpresaByConta(conta, this.accountCompanies);
}
private normalizeConta(value: any): string {
return normalizeContaValue(value);
}
private sameConta(a: any, b: any): boolean {
return sameContaValue(a, b);
}
private syncContaEmpresaSelection(model: any) {
if (!model) return;
const contaAtual = (model.conta ?? '').toString().trim();
const empresaAtual = (model.contaEmpresa ?? '').toString().trim();
if (!contaAtual) {
if (!empresaAtual) {
model.contaEmpresa = '';
}
return;
}
const empresaPorConta = this.findEmpresaByConta(contaAtual);
if (empresaPorConta) {
model.contaEmpresa = empresaPorConta;
return;
}
if (!empresaAtual) {
model.contaEmpresa = '';
}
}
private validateContaEmpresaBinding(model: any): string | null {
if (!model) return 'Dados da linha inválidos.';
this.syncContaEmpresaSelection(model);
const conta = (model.conta ?? '').toString().trim();
const contaEmpresa = (model.contaEmpresa ?? '').toString().trim();
if (!contaEmpresa) return 'Selecione a Empresa (Conta).';
if (!conta) return 'Selecione uma Conta.';
const empresaPorConta = this.findEmpresaByConta(conta);
if (!empresaPorConta) {
return 'A conta informada não está vinculada a nenhuma Empresa (Conta) cadastrada.';
}
if (empresaPorConta.localeCompare(contaEmpresa, 'pt-BR', { sensitivity: 'base' }) !== 0) {
model.contaEmpresa = empresaPorConta;
}
return null;
}
}