line-gestao-frontend/src/app/services/table-export.service.ts

463 lines
16 KiB
TypeScript

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<T> {
header: string;
key?: string;
type?: ExportCellType;
width?: number;
value: (row: T, index: number) => unknown;
}
export interface TableExportRequest<T> {
fileName: string;
sheetName?: string;
columns: TableExportColumn<T>[];
rows: T[];
templateBuffer?: ArrayBuffer | null;
}
type CellStyleSnapshot = {
font?: Partial<import('exceljs').Font>;
fill?: import('exceljs').Fill;
border?: Partial<import('exceljs').Borders>;
alignment?: Partial<import('exceljs').Alignment>;
};
type TemplateStyleSnapshot = {
headerStyles: CellStyleSnapshot[];
bodyStyle?: CellStyleSnapshot;
bodyAltStyle?: CellStyleSnapshot;
columnWidths: Array<number | undefined>;
};
@Injectable({ providedIn: 'root' })
export class TableExportService {
private readonly templatesApiBase = (() => {
const apiBase = buildApiBaseUrl(environment.apiUrl);
return `${apiBase}/templates`;
})();
private defaultTemplateBufferPromise: Promise<ArrayBuffer | null> | null = null;
private cachedDefaultTemplateStyle?: TemplateStyleSnapshot;
constructor(private readonly http: HttpClient) {}
async exportAsXlsx<T>(request: TableExportRequest<T>): Promise<void> {
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<T>(
sheet: import('exceljs').Worksheet,
columns: TableExportColumn<T>[],
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<T>(
sheet: import('exceljs').Worksheet,
columns: TableExportColumn<T>[],
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<import('exceljs').Alignment> {
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<import('exceljs').Borders> {
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<TemplateStyleSnapshot | undefined> {
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<TemplateStyleSnapshot | undefined> {
if (templateBuffer) {
const style = await this.extractTemplateStyle(excelJsModule, templateBuffer);
if (style) this.cachedDefaultTemplateStyle = style;
return style;
}
return this.cachedDefaultTemplateStyle;
}
private async getDefaultTemplateBuffer(): Promise<ArrayBuffer | null> {
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<ArrayBuffer | null> {
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<T>(value: T | undefined): T | undefined {
if (!value) return undefined;
try {
return JSON.parse(JSON.stringify(value)) as T;
} catch {
return value;
}
}
}