line-gestao-frontend/src/app/pages/troca-numero/troca-numero.ts

712 lines
21 KiB
TypeScript

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<T> {
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<string, TrocaRow[]>();
// 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<HTMLElement>('[data-animate]');
items.forEach((el) => el.classList.add('is-visible'));
}, 100);
}
refresh() {
this.page = 1;
this.loadForGroups();
}
async onExport(): Promise<void> {
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<TrocaRow>({
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<TrocaRow[]> {
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<ApiPagedResult<any> | 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<ApiPagedResult<any> | 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<string[]>(`${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<LineOptionDto[]>(`${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);
}
}
}