import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import { environment } from '../../environments/environment'; import { buildApiBaseUrl } from '../utils/api-base.util'; export type ExportCellType = 'text' | 'number' | 'currency' | 'date' | 'datetime' | 'boolean'; export interface TableExportColumn { header: string; key?: string; type?: ExportCellType; width?: number; value: (row: T, index: number) => unknown; } export interface TableExportRequest { fileName: string; sheetName?: string; columns: TableExportColumn[]; rows: T[]; templateBuffer?: ArrayBuffer | null; } type CellStyleSnapshot = { font?: Partial; fill?: import('exceljs').Fill; border?: Partial; alignment?: Partial; }; type TemplateStyleSnapshot = { headerStyles: CellStyleSnapshot[]; bodyStyle?: CellStyleSnapshot; bodyAltStyle?: CellStyleSnapshot; columnWidths: Array; }; @Injectable({ providedIn: 'root' }) export class TableExportService { private readonly templatesApiBase = (() => { const apiBase = buildApiBaseUrl(environment.apiUrl); return `${apiBase}/templates`; })(); private defaultTemplateBufferPromise: Promise | null = null; private cachedDefaultTemplateStyle?: TemplateStyleSnapshot; constructor(private readonly http: HttpClient) {} async exportAsXlsx(request: TableExportRequest): Promise { const ExcelJS = await import('exceljs'); const templateBuffer = request.templateBuffer ?? (await this.getDefaultTemplateBuffer()); const templateStyle = await this.resolveTemplateStyle(ExcelJS, templateBuffer); const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet(this.sanitizeSheetName(request.sheetName || 'Dados')); const rawColumns = request.columns ?? []; const columns = rawColumns.filter((column) => !this.shouldExcludeColumnByHeader(column.header)); const rows = request.rows ?? []; if (!columns.length) { throw new Error('Nenhuma coluna exportavel apos remover ITEM/ID.'); } const headerValues = columns.map((c) => c.header ?? ''); sheet.addRow(headerValues); rows.forEach((row, rowIndex) => { const values = columns.map((column) => this.normalizeValue(column.value(row, rowIndex), column.type)); sheet.addRow(values); }); this.applyHeaderStyle(sheet, columns.length, templateStyle); this.applyBodyStyle(sheet, columns, rows.length, templateStyle); this.applyColumnWidths(sheet, columns, rows, templateStyle); this.applyAutoFilter(sheet, columns.length); sheet.views = [{ state: 'frozen', ySplit: 1 }]; const extensionSafeName = this.ensureXlsxExtension(request.fileName); const buffer = await workbook.xlsx.writeBuffer(); this.downloadBuffer(buffer, extensionSafeName); } buildTimestamp(date: Date = new Date()): string { const year = date.getFullYear(); const month = this.pad2(date.getMonth() + 1); const day = this.pad2(date.getDate()); const hour = this.pad2(date.getHours()); const minute = this.pad2(date.getMinutes()); return `${year}-${month}-${day}_${hour}-${minute}`; } private applyHeaderStyle( sheet: import('exceljs').Worksheet, columnCount: number, templateStyle?: TemplateStyleSnapshot, ): void { const headerRow = sheet.getRow(1); headerRow.height = 24; for (let col = 1; col <= columnCount; col += 1) { const cell = headerRow.getCell(col); const templateCell = this.getTemplateStyleByIndex(templateStyle, col - 1); cell.font = this.cloneStyle(templateCell?.font) || { bold: true, color: { argb: 'FFFFFFFF' }, name: 'Calibri', size: 11 }; cell.fill = this.cloneStyle(templateCell?.fill) || { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0A58CA' }, }; cell.alignment = this.cloneStyle(templateCell?.alignment) || { vertical: 'middle', horizontal: 'center', wrapText: true }; cell.border = this.cloneStyle(templateCell?.border) || this.getDefaultBorder(); } } private applyBodyStyle( sheet: import('exceljs').Worksheet, columns: TableExportColumn[], rowCount: number, templateStyle?: TemplateStyleSnapshot, ): void { for (let rowIndex = 2; rowIndex <= rowCount + 1; rowIndex += 1) { const row = sheet.getRow(rowIndex); const isEven = (rowIndex - 1) % 2 === 0; const templateRowStyle = isEven ? (templateStyle?.bodyAltStyle ?? templateStyle?.bodyStyle) : (templateStyle?.bodyStyle ?? templateStyle?.bodyAltStyle); columns.forEach((column, columnIndex) => { const cell = row.getCell(columnIndex + 1); cell.font = this.cloneStyle(templateRowStyle?.font) || { name: 'Calibri', size: 11, color: { argb: 'FF1F2937' } }; cell.border = this.cloneStyle(templateRowStyle?.border) || this.getDefaultBorder(); cell.alignment = this.cloneStyle(templateRowStyle?.alignment) || this.getAlignment(column.type); if (templateRowStyle?.fill) { const fill = this.cloneStyle(templateRowStyle.fill); if (fill) cell.fill = fill; } else if (isEven) { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF7FAFF' }, }; } if (column.type === 'number') cell.numFmt = '#,##0.00'; if (column.type === 'currency') cell.numFmt = '"R$" #,##0.00'; if (column.type === 'date') cell.numFmt = 'dd/mm/yyyy'; if (column.type === 'datetime') cell.numFmt = 'dd/mm/yyyy hh:mm'; }); } } private applyColumnWidths( sheet: import('exceljs').Worksheet, columns: TableExportColumn[], rows: T[], templateStyle?: TemplateStyleSnapshot, ): void { columns.forEach((column, columnIndex) => { if (column.width && column.width > 0) { sheet.getColumn(columnIndex + 1).width = column.width; return; } const templateWidth = templateStyle?.columnWidths?.[columnIndex]; if (templateWidth && templateWidth > 0) { sheet.getColumn(columnIndex + 1).width = templateWidth; return; } const headerLength = (column.header ?? '').length; let maxLength = headerLength; rows.forEach((row, rowIndex) => { const value = column.value(row, rowIndex); const printable = this.toPrintableValue(value, column.type); if (printable.length > maxLength) maxLength = printable.length; }); const target = Math.max(12, Math.min(maxLength + 3, 48)); sheet.getColumn(columnIndex + 1).width = target; }); } private applyAutoFilter(sheet: import('exceljs').Worksheet, columnCount: number): void { if (columnCount <= 0) return; sheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: columnCount }, }; } private normalizeValue(value: unknown, type?: ExportCellType): string | number | Date | boolean | null { if (value === null || value === undefined || value === '') return null; if (type === 'number' || type === 'currency') { const numeric = this.toNumber(value); return numeric ?? String(value); } if (type === 'date' || type === 'datetime') { const parsedDate = this.toDate(value); return parsedDate ?? String(value); } if (type === 'boolean') { if (typeof value === 'boolean') return value; return this.normalizeBoolean(value); } return String(value); } private toPrintableValue(value: unknown, type?: ExportCellType): string { if (value === null || value === undefined || value === '') return ''; if (type === 'date' || type === 'datetime') { const parsedDate = this.toDate(value); if (!parsedDate) return String(value); const datePart = `${this.pad2(parsedDate.getDate())}/${this.pad2(parsedDate.getMonth() + 1)}/${parsedDate.getFullYear()}`; if (type === 'date') return datePart; return `${datePart} ${this.pad2(parsedDate.getHours())}:${this.pad2(parsedDate.getMinutes())}`; } if (type === 'number' || type === 'currency') { const numeric = this.toNumber(value); if (numeric === null) return String(value); if (type === 'currency') { return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(numeric); } return new Intl.NumberFormat('pt-BR').format(numeric); } if (type === 'boolean') { return this.normalizeBoolean(value) ? 'Sim' : 'Nao'; } return String(value); } private toNumber(value: unknown): number | null { if (typeof value === 'number') { return Number.isFinite(value) ? value : null; } if (typeof value === 'string') { const trimmed = value.trim(); if (!trimmed) return null; const normalized = trimmed .replace(/[^\d,.-]/g, '') .replace(/\.(?=\d{3}(\D|$))/g, '') .replace(',', '.'); const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : null; } return null; } private toDate(value: unknown): Date | null { if (value instanceof Date) { return Number.isNaN(value.getTime()) ? null : value; } if (typeof value === 'string') { const trimmed = value.trim(); if (!trimmed) return null; const brDate = trimmed.match(/^(\d{2})\/(\d{2})\/(\d{4})(?:\s+(\d{2}):(\d{2}))?$/); if (brDate) { const day = Number(brDate[1]); const month = Number(brDate[2]) - 1; const year = Number(brDate[3]); const hour = Number(brDate[4] ?? '0'); const minute = Number(brDate[5] ?? '0'); const parsedBr = new Date(year, month, day, hour, minute); return Number.isNaN(parsedBr.getTime()) ? null : parsedBr; } const parsed = new Date(trimmed); return Number.isNaN(parsed.getTime()) ? null : parsed; } return null; } private normalizeBoolean(value: unknown): boolean { if (typeof value === 'boolean') return value; const normalized = String(value ?? '') .trim() .toLowerCase(); return normalized === 'true' || normalized === '1' || normalized === 'sim' || normalized === 'yes'; } private ensureXlsxExtension(fileName: string): string { const safe = (fileName ?? 'export').trim() || 'export'; return safe.toLowerCase().endsWith('.xlsx') ? safe : `${safe}.xlsx`; } private sanitizeSheetName(name: string): string { const safe = (name ?? 'Dados').replace(/[\\/*?:[\]]/g, '').trim(); return (safe || 'Dados').slice(0, 31); } private shouldExcludeColumnByHeader(header: string | undefined): boolean { const normalized = this.normalizeHeader(header); if (!normalized) return false; const tokens = normalized.split(/[^a-z0-9]+/).filter(Boolean); if (!tokens.length) return false; return tokens.includes('id') || tokens.includes('item'); } private normalizeHeader(value: string | undefined): string { return (value ?? '') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .trim(); } private downloadBuffer(buffer: ArrayBuffer, fileName: string): void { const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName; document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); URL.revokeObjectURL(url); } private getAlignment(type?: ExportCellType): Partial { if (type === 'number' || type === 'currency') { return { vertical: 'middle', horizontal: 'right' }; } if (type === 'boolean') { return { vertical: 'middle', horizontal: 'center' }; } return { vertical: 'middle', horizontal: 'left', wrapText: true }; } private getDefaultBorder(): Partial { return { top: { style: 'thin', color: { argb: 'FFD6DCE8' } }, left: { style: 'thin', color: { argb: 'FFD6DCE8' } }, right: { style: 'thin', color: { argb: 'FFD6DCE8' } }, bottom: { style: 'thin', color: { argb: 'FFD6DCE8' } }, }; } private pad2(value: number): string { return value.toString().padStart(2, '0'); } private async extractTemplateStyle( excelJsModule: typeof import('exceljs'), templateBuffer: ArrayBuffer | null, ): Promise { if (!templateBuffer) return undefined; try { const workbook = new excelJsModule.Workbook(); await workbook.xlsx.load(templateBuffer); const sheet = workbook.getWorksheet(1); if (!sheet) return undefined; const headerRow = sheet.getRow(1); const headerCount = Math.max(headerRow.actualCellCount, 1); const headerStyles: CellStyleSnapshot[] = []; for (let col = 1; col <= headerCount; col += 1) { headerStyles.push(this.captureCellStyle(headerRow.getCell(col))); } const bodyStyle = this.captureFirstStyledCellRow(sheet.getRow(2)); const bodyAltStyle = this.captureFirstStyledCellRow(sheet.getRow(3)); const columnWidths = (sheet.columns ?? []).map((column) => column.width); return { headerStyles, bodyStyle, bodyAltStyle, columnWidths }; } catch { return undefined; } } private async resolveTemplateStyle( excelJsModule: typeof import('exceljs'), templateBuffer: ArrayBuffer | null, ): Promise { if (templateBuffer) { const style = await this.extractTemplateStyle(excelJsModule, templateBuffer); if (style) this.cachedDefaultTemplateStyle = style; return style; } return this.cachedDefaultTemplateStyle; } private async getDefaultTemplateBuffer(): Promise { if (this.defaultTemplateBufferPromise) { return this.defaultTemplateBufferPromise; } this.defaultTemplateBufferPromise = this.fetchDefaultTemplateBuffer(); const buffer = await this.defaultTemplateBufferPromise; if (!buffer) this.defaultTemplateBufferPromise = null; return buffer; } private async fetchDefaultTemplateBuffer(): Promise { try { const params = new HttpParams().set('_', `${Date.now()}`); const blob = await firstValueFrom( this.http.get(`${this.templatesApiBase}/planilha-geral`, { params, responseType: 'blob', }) ); return await blob.arrayBuffer(); } catch { return null; } } private captureFirstStyledCellRow(row: import('exceljs').Row): CellStyleSnapshot | undefined { if (!row) return undefined; const cellCount = Math.max(row.actualCellCount, 1); for (let col = 1; col <= cellCount; col += 1) { const captured = this.captureCellStyle(row.getCell(col)); if (captured.font || captured.fill || captured.border || captured.alignment) { return captured; } } return undefined; } private captureCellStyle(cell: import('exceljs').Cell): CellStyleSnapshot { return { font: this.cloneStyle(cell.font), fill: this.cloneStyle(cell.fill), border: this.cloneStyle(cell.border), alignment: this.cloneStyle(cell.alignment), }; } private getTemplateStyleByIndex(style: TemplateStyleSnapshot | undefined, index: number): CellStyleSnapshot | undefined { if (!style || !style.headerStyles.length) return undefined; return style.headerStyles[index] ?? style.headerStyles[style.headerStyles.length - 1]; } private cloneStyle(value: T | undefined): T | undefined { if (!value) return undefined; try { return JSON.parse(JSON.stringify(value)) as T; } catch { return value; } } }