5535 lines
177 KiB
TypeScript
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;
|
|
}
|
|
}
|