463 lines
16 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|