import { Component, ElementRef, ViewChild, Inject, PLATFORM_ID, AfterViewInit, ChangeDetectorRef } from '@angular/core'; import { isPlatformBrowser, CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpClient, HttpParams } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import { AuthService } from '../../services/auth.service'; import { CustomSelectComponent } from '../../components/custom-select/custom-select'; import { TableExportService } from '../../services/table-export.service'; import { environment } from '../../../environments/environment'; import { TrocaNumeroModalsComponent } from '../../components/page-modals/troca-numero-modals/troca-numero-modals'; import { buildPageNumbers, clampPage, computePageEnd, computePageStart, computeTotalPages } from '../../utils/pagination.util'; import { buildApiEndpoint } from '../../utils/api-base.util'; type TrocaKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataTroca' | 'motivo' | 'observacao'; interface TrocaRow { id: string; item: string; linhaAntiga: string; linhaNova: string; iccid: string; dataTroca: string; motivo: string; observacao: string; raw: any; } interface ApiPagedResult { page?: number; pageSize?: number; total?: number; items?: T[]; } interface GroupItem { key: string; // aqui é o MOTIVO total: number; trocas: number; comIccid: number; semIccid: number; } /** ✅ DTO da linha do GERAL (para selects) */ interface LineOptionDto { id: string; item: number; linha: string | null; chip: string | null; cliente: string | null; usuario: string | null; skil: string | null; label?: string; } @Component({ standalone: true, imports: [CommonModule, FormsModule, CustomSelectComponent, TrocaNumeroModalsComponent], templateUrl: './troca-numero.html', styleUrls: ['./troca-numero.scss'] }) export class TrocaNumero implements AfterViewInit { readonly vm = this; toastMessage = ''; loading = false; exporting = false; @ViewChild('successToast', { static: false }) successToast!: ElementRef; constructor( @Inject(PLATFORM_ID) private platformId: object, private http: HttpClient, private cdr: ChangeDetectorRef, private authService: AuthService, private tableExportService: TableExportService ) {} private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'trocanumero'); /** ✅ base do GERAL (para buscar clientes/linhas no modal) */ private readonly linesApiBase = buildApiEndpoint(environment.apiUrl, 'lines'); // ====== DATA ====== groups: GroupItem[] = []; pagedGroups: GroupItem[] = []; expandedGroup: string | null = null; groupRows: TrocaRow[] = []; private rowsByKey = new Map(); // KPIs groupLoadedRecords = 0; groupTotalTrocas = 0; groupTotalIccids = 0; // ====== FILTERS & PAGINATION ====== searchTerm = ''; private searchTimer: any = null; page = 1; pageSize = 10; pageSizeOptions = [10, 20, 50, 100]; total = 0; // ====== EDIT MODAL ====== editOpen = false; editSaving = false; editModel: any = null; // ====== CREATE MODAL ====== createOpen = false; createSaving = false; createModel: any = { item: '', linhaAntiga: '', linhaNova: '', iccid: '', dataTroca: '', motivo: '', observacao: '' }; /** ✅ selects do GERAL no modal */ clientsFromGeral: string[] = []; linesFromClient: LineOptionDto[] = []; selectedCliente: string = ''; selectedLineId: string = ''; loadingClients = false; loadingLines = false; isSysAdmin = false; isGestor = false; isFinanceiro = false; get canManageRecords(): boolean { return this.isSysAdmin || this.isGestor; } async ngAfterViewInit() { if (!isPlatformBrowser(this.platformId)) return; this.initAnimations(); this.isSysAdmin = this.authService.hasRole('sysadmin'); this.isGestor = this.authService.hasRole('gestor'); this.isFinanceiro = this.authService.hasRole('financeiro'); setTimeout(() => this.refresh()); } private initAnimations() { document.documentElement.classList.add('js-animate'); setTimeout(() => { const items = document.querySelectorAll('[data-animate]'); items.forEach((el) => el.classList.add('is-visible')); }, 100); } refresh() { this.page = 1; this.loadForGroups(); } async onExport(): Promise { if (this.exporting) return; this.exporting = true; try { const rows = await this.fetchAllRowsForExport(); if (!rows.length) { await this.showToast('Nenhum registro encontrado para exportar.'); return; } const timestamp = this.tableExportService.buildTimestamp(); await this.tableExportService.exportAsXlsx({ fileName: `troca_numero_${timestamp}`, sheetName: 'TrocaNumero', rows, columns: [ { header: 'ID', value: (row) => row.id ?? '' }, { header: 'Motivo', value: (row) => row.motivo }, { header: 'Cliente', value: (row) => this.getRawField(row, ['cliente', 'Cliente']) ?? '' }, { header: 'Usuario', value: (row) => this.getRawField(row, ['usuario', 'Usuario']) ?? '' }, { header: 'Skil', value: (row) => this.getRawField(row, ['skil', 'Skil']) ?? '' }, { header: 'Item', type: 'number', value: (row) => this.toNumberOrNull(row.item) ?? 0 }, { header: 'Linha Antiga', value: (row) => row.linhaAntiga }, { header: 'Linha Nova', value: (row) => row.linhaNova }, { header: 'ICCID', value: (row) => row.iccid }, { header: 'Data da Troca', type: 'date', value: (row) => row.dataTroca }, { header: 'Observacao', value: (row) => row.observacao }, { header: 'Situacao', value: (row) => (this.isTroca(row) ? 'TROCA' : 'SEM TROCA') }, { header: 'Linha ID (Geral)', value: (row) => this.getRawField(row, ['mobileLineId', 'MobileLineId']) ?? '' }, { header: 'Criado Em', type: 'datetime', value: (row) => this.getRawField(row, ['createdAt', 'CreatedAt']) ?? '' }, { header: 'Atualizado Em', type: 'datetime', value: (row) => this.getRawField(row, ['updatedAt', 'UpdatedAt']) ?? '' }, ], }); await this.showToast(`Planilha exportada com ${rows.length} registro(s).`); } catch { await this.showToast('Erro ao exportar planilha.'); } finally { this.exporting = false; } } private async fetchAllRowsForExport(): Promise { const pageSize = 2000; let page = 1; let expectedTotal = 0; const rows: TrocaRow[] = []; while (page <= 500) { const params = new HttpParams() .set('page', String(page)) .set('pageSize', String(pageSize)) .set('search', (this.searchTerm ?? '').trim()) .set('sortBy', 'motivo') .set('sortDir', 'asc'); const response = await firstValueFrom( this.http.get | any[]>(this.apiBase, { params }) ); const items = Array.isArray(response) ? response : (response.items ?? []); const normalized = items.map((item: any, idx: number) => this.normalizeRow(item, rows.length + idx)); rows.push(...normalized); expectedTotal = Array.isArray(response) ? 0 : Number(response.total ?? 0); if (Array.isArray(response)) break; if (items.length === 0) break; if (items.length < pageSize) break; if (expectedTotal > 0 && rows.length >= expectedTotal) break; page += 1; } return rows.sort((a, b) => { const byMotivo = (a.motivo ?? '').localeCompare(b.motivo ?? '', 'pt-BR', { sensitivity: 'base' }); if (byMotivo !== 0) return byMotivo; const byItem = (this.toNumberOrNull(a.item) ?? 0) - (this.toNumberOrNull(b.item) ?? 0); if (byItem !== 0) return byItem; return (a.linhaNova ?? '').localeCompare(b.linhaNova ?? '', 'pt-BR', { sensitivity: 'base' }); }); } onSearch() { if (this.searchTimer) clearTimeout(this.searchTimer); this.searchTimer = setTimeout(() => { this.page = 1; this.expandedGroup = null; this.groupRows = []; this.loadForGroups(); }, 300); } clearSearch() { this.searchTerm = ''; this.page = 1; this.expandedGroup = null; this.groupRows = []; this.loadForGroups(); } onPageSizeChange() { this.page = 1; this.applyPagination(); } goToPage(p: number) { this.page = clampPage(p, this.totalPages); this.applyPagination(); } get totalPages() { return computeTotalPages(this.total || 0, this.pageSize); } get pageNumbers() { return buildPageNumbers(this.page, this.totalPages); } get pageStart() { return computePageStart(this.total || 0, this.page, this.pageSize); } get pageEnd() { return computePageEnd(this.total || 0, this.page, this.pageSize); } trackById(_: number, row: TrocaRow) { return row.id; } // ======================================================================= // LOAD LOGIC (igual MUREG: puxa bastante e agrupa no front) // ======================================================================= private loadForGroups() { this.loading = true; const MAX_FETCH = 5000; let params = new HttpParams() .set('page', '1') .set('pageSize', String(MAX_FETCH)) .set('search', (this.searchTerm ?? '').trim()) .set('sortBy', 'motivo') .set('sortDir', 'asc'); this.http.get | any[]>(this.apiBase, { params }).subscribe({ next: (res: any) => { const items = Array.isArray(res) ? res : (res.items ?? []); const normalized = (items ?? []).map((x: any, idx: number) => this.normalizeRow(x, idx)); this.buildGroups(normalized); this.applyPagination(); this.loading = false; this.cdr.detectChanges(); }, error: async () => { this.loading = false; await this.showToast('Erro ao carregar Troca de Número.'); } }); } private buildGroups(all: TrocaRow[]) { this.rowsByKey.clear(); const safeKey = (v: any) => (String(v ?? '').trim() || 'SEM MOTIVO'); for (const r of all) { const key = safeKey(r.motivo); r.motivo = key; const arr = this.rowsByKey.get(key) ?? []; arr.push(r); this.rowsByKey.set(key, arr); } const groups: GroupItem[] = []; let trocasTotal = 0; let iccidsTotal = 0; this.rowsByKey.forEach((arr, key) => { const total = arr.length; const trocas = arr.filter(x => this.isTroca(x)).length; const comIccid = arr.filter(x => String(x.iccid ?? '').trim() !== '').length; const semIccid = total - comIccid; trocasTotal += trocas; iccidsTotal += comIccid; groups.push({ key, total, trocas, comIccid, semIccid }); }); groups.sort((a, b) => a.key.localeCompare(b.key, 'pt-BR', { sensitivity: 'base' })); this.groups = groups; this.total = groups.length; this.groupLoadedRecords = all.length; this.groupTotalTrocas = trocasTotal; this.groupTotalIccids = iccidsTotal; } private applyPagination() { const start = (this.page - 1) * this.pageSize; const end = start + this.pageSize; this.pagedGroups = this.groups.slice(start, end); if (this.expandedGroup && !this.pagedGroups.some(g => g.key === this.expandedGroup)) { this.expandedGroup = null; this.groupRows = []; } } toggleGroup(key: string) { if (this.expandedGroup === key) { this.expandedGroup = null; this.groupRows = []; return; } this.expandedGroup = key; const rows = this.rowsByKey.get(key) ?? []; this.groupRows = [...rows].sort((a, b) => { const ai = parseInt(String(a.item ?? '0'), 10); const bi = parseInt(String(b.item ?? '0'), 10); if (Number.isFinite(ai) && Number.isFinite(bi) && ai !== bi) return ai - bi; return String(a.linhaNova ?? '').localeCompare(String(b.linhaNova ?? ''), 'pt-BR', { sensitivity: 'base' }); }); } isTroca(r: TrocaRow): boolean { const a = String(r.linhaAntiga ?? '').trim(); const b = String(r.linhaNova ?? '').trim(); if (!a || !b) return false; return a !== b; } private normalizeRow(x: any, idx: number): TrocaRow { const pick = (obj: any, keys: string[]): any => { for (const k of keys) { if (obj && obj[k] !== undefined && obj[k] !== null && String(obj[k]).trim() !== '') return obj[k]; } return ''; }; const item = pick(x, ['item', 'ITEM', 'ITÉM']); const linhaAntiga = pick(x, ['linhaAntiga', 'linha_antiga', 'LINHA ANTIGA']); const linhaNova = pick(x, ['linhaNova', 'linha_nova', 'LINHA NOVA']); const iccid = pick(x, ['iccid', 'ICCID']); const dataTroca = pick(x, ['dataTroca', 'data_troca', 'DATA TROCA', 'DATA DA TROCA']); const motivo = pick(x, ['motivo', 'MOTIVO']); const observacao = pick(x, ['observacao', 'OBSERVAÇÃO', 'OBSERVACAO']); const id = String(pick(x, ['id', 'ID']) || `${idx}-${item}-${linhaNova}-${iccid}`); return { id, item: String(item ?? ''), linhaAntiga: String(linhaAntiga ?? ''), linhaNova: String(linhaNova ?? ''), iccid: String(iccid ?? ''), dataTroca: String(dataTroca ?? ''), motivo: String(motivo ?? ''), observacao: String(observacao ?? ''), raw: x }; } // ======================================================================= // ✅ GERAL -> selects do modal (Clientes / Linhas do cliente) // ======================================================================= private loadClientsFromGeral() { this.loadingClients = true; this.http.get(`${this.linesApiBase}/clients`).subscribe({ next: (res) => { this.clientsFromGeral = (res ?? []).filter(x => !!String(x ?? '').trim()); this.loadingClients = false; this.cdr.detectChanges(); }, error: async () => { this.loadingClients = false; await this.showToast('Erro ao carregar clientes do GERAL.'); } }); } private loadLinesByClient(cliente: string) { const c = String(cliente ?? '').trim(); if (!c) { this.linesFromClient = []; this.selectedLineId = ''; return; } this.loadingLines = true; const params = new HttpParams().set('cliente', c); this.http.get(`${this.linesApiBase}/by-client`, { params }).subscribe({ next: (res) => { this.linesFromClient = (res ?? []).map((x) => ({ ...x, label: `${x.item ?? ''} • ${x.linha ?? '-'} • ${x.usuario ?? 'SEM USUÁRIO'}` })); this.loadingLines = false; this.cdr.detectChanges(); }, error: async () => { this.loadingLines = false; await this.showToast('Erro ao carregar linhas do cliente (GERAL).'); } }); } onClienteChange() { // reset quando troca cliente this.selectedLineId = ''; this.linesFromClient = []; // limpa campos auto this.createModel.linhaAntiga = ''; this.createModel.iccid = ''; this.loadLinesByClient(this.selectedCliente); } onLineChange() { const id = String(this.selectedLineId ?? '').trim(); const found = this.linesFromClient.find(x => x.id === id); // preenche automaticamente a partir do GERAL this.createModel.linhaAntiga = found?.linha ?? ''; this.createModel.iccid = found?.chip ?? ''; // Chip do GERAL => ICCID aqui // se quiser, pode setar item automaticamente também: if (found?.item !== undefined && found?.item !== null) { // só seta se estiver vazio (pra não atrapalhar quem quiser digitar) if (!String(this.createModel.item ?? '').trim()) { this.createModel.item = String(found.item); } } } // ====== MODAL EDIÇÃO ====== onEditar(r: TrocaRow) { if (!this.canManageRecords) { this.showToast('Perfil Financeiro possui acesso somente leitura.'); return; } this.editOpen = true; this.editSaving = false; this.editModel = { id: r.id, item: r.item, linhaAntiga: r.linhaAntiga, linhaNova: r.linhaNova, iccid: r.iccid, motivo: r.motivo, observacao: r.observacao, dataTroca: this.isoToDateInput(r.dataTroca) }; } closeEdit() { this.editOpen = false; this.editModel = null; this.editSaving = false; } saveEdit() { if (!this.canManageRecords) { this.showToast('Perfil Financeiro possui acesso somente leitura.'); return; } if (!this.editModel || !this.editModel.id) return; this.editSaving = true; const payload = { item: this.toNumberOrNull(this.editModel.item), linhaAntiga: this.editModel.linhaAntiga, linhaNova: this.editModel.linhaNova, iccid: this.editModel.iccid, motivo: this.editModel.motivo, observacao: this.editModel.observacao, dataTroca: this.dateInputToIso(this.editModel.dataTroca) }; this.http.put(`${this.apiBase}/${this.editModel.id}`, payload).subscribe({ next: async () => { this.editSaving = false; await this.showToast('Registro atualizado com sucesso!'); this.closeEdit(); const currentGroup = this.expandedGroup; this.loadForGroups(); if (currentGroup) setTimeout(() => this.expandedGroup = currentGroup, 350); }, error: async () => { this.editSaving = false; await this.showToast('Erro ao salvar edição.'); } }); } // ====== MODAL CRIAÇÃO ====== onCreate() { if (!this.canManageRecords) { this.showToast('Perfil Financeiro possui acesso somente leitura.'); return; } this.createOpen = true; this.createSaving = false; // reset do form this.createModel = { item: '', linhaAntiga: '', linhaNova: '', iccid: '', dataTroca: '', motivo: '', observacao: '' }; // reset dos selects this.selectedCliente = ''; this.selectedLineId = ''; this.clientsFromGeral = []; this.linesFromClient = []; // carrega clientes do GERAL this.loadClientsFromGeral(); } closeCreate() { this.createOpen = false; } saveCreate() { if (!this.canManageRecords) { this.showToast('Perfil Financeiro possui acesso somente leitura.'); return; } // ✅ validações do "beber do GERAL" if (!String(this.selectedCliente ?? '').trim()) { this.showToast('Selecione um Cliente do GERAL.'); return; } if (!String(this.selectedLineId ?? '').trim()) { this.showToast('Selecione uma Linha do Cliente (GERAL).'); return; } if (!String(this.createModel.linhaNova ?? '').trim()) { this.showToast('Informe a Linha Nova.'); return; } this.createSaving = true; const payload = { item: this.toNumberOrNull(this.createModel.item), linhaAntiga: this.createModel.linhaAntiga, // auto do GERAL linhaNova: this.createModel.linhaNova, iccid: this.createModel.iccid, // auto do GERAL motivo: this.createModel.motivo, observacao: this.createModel.observacao, dataTroca: this.dateInputToIso(this.createModel.dataTroca) }; this.http.post(this.apiBase, payload).subscribe({ next: async () => { this.createSaving = false; await this.showToast('Troca criada com sucesso!'); this.closeCreate(); this.loadForGroups(); }, error: async () => { this.createSaving = false; await this.showToast('Erro ao criar Troca.'); } }); } // Helpers private toNumberOrNull(v: any): number | null { const n = parseInt(String(v ?? '').trim(), 10); return Number.isFinite(n) ? n : null; } private getRawField(row: TrocaRow, keys: string[]): string | null { for (const key of keys) { const value = row?.raw?.[key]; if (value === undefined || value === null || String(value).trim() === '') continue; return String(value); } return null; } private isoToDateInput(iso: string | null | undefined): string { if (!iso) return ''; const dt = new Date(iso); if (Number.isNaN(dt.getTime())) return ''; return dt.toISOString().slice(0, 10); } private dateInputToIso(val: string | null | undefined): string | null { if (!val) return null; const dt = new Date(val); if (Number.isNaN(dt.getTime())) return null; return dt.toISOString(); } displayValue(key: TrocaKey, v: any): string { if (v === null || v === undefined || String(v).trim() === '') return '-'; if (key === 'dataTroca') { const s = String(v).trim(); const d = new Date(s); if (!Number.isNaN(d.getTime())) return new Intl.DateTimeFormat('pt-BR').format(d); return s; } return String(v); } 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); } } }