- Módulo Visitas completo (auto desde agenda, insumos con descargo de inventario, fotos antes/después y documentos, receta imprimible) - Punto de Venta (catálogo + ticket sticky, cobro con cambio, pago con puntos monedero, ticket imprimible) - WACRM: leads automáticos desde WhatsApp, asignación de conversaciones y leads a agentes, conversión lead→paciente, ficha del paciente en chat - Pacientes: completitud de expediente, alertas clínicas, historial unificado con detalle, foto, WhatsApp, estado de cuenta, filtros rápidos (VIP/recientes/médico), documentos (expediente escaneado + galería) - Agenda: vistas por médico y por hora, filtros rápidos (libres, primera vez, check-in, no-show), modal de acciones, bloqueos por médico, drag&drop para mover citas - Reportes: 18 pestañas (diario, cortes, ingresos, inventario, adeudos, comisiones, pagos, devoluciones, top clientes, horas, paquetes, vendedores, concentrado, recomendaciones, KPIs) con exportación Excel - Temas: nuevo tema Clásico (look legacy AdminLTE) con submenús tipo treeview, selector de tema; accesos rápidos personalizables con 3 presentaciones; búsqueda global; notificaciones reales - Configuración: secciones (clínica, usuarios con permisos por sección, recetas, catálogos de diagnósticos y procedimientos) - Inventario: alertas de caducidad y sugerencia de compra, cron diario que descuenta artículos caducados, compras/bajas - Consultas Médicas, página Expedientes, importadores delta (citas/visitas legacy idempotentes), depuración de duplicados - Infra: tema Tailwind conectado (@config), gzip en nginx, secuencias Odoo corregidas (noupdate, company_id), rollback en validaciones
149 lines
4.8 KiB
TypeScript
149 lines
4.8 KiB
TypeScript
/**
|
|
* Exportación de reportes a Excel (.xlsx) con formato de marca SKEEN.
|
|
* Usa exceljs cargado on-demand (dynamic import) para no inflar el bundle inicial.
|
|
*/
|
|
|
|
export interface ExportColumn {
|
|
header: string;
|
|
key: string;
|
|
width?: number;
|
|
format?: 'currency' | 'number' | 'date' | 'text' | 'percent';
|
|
}
|
|
|
|
export interface ExportOptions {
|
|
filename: string;
|
|
sheetName: string;
|
|
title: string;
|
|
subtitle?: string;
|
|
columns: ExportColumn[];
|
|
rows: Record<string, unknown>[];
|
|
totals?: Record<string, unknown>;
|
|
}
|
|
|
|
const NUM_FORMATS: Record<string, string> = {
|
|
currency: '"$"#,##0.00',
|
|
number: '#,##0',
|
|
date: 'DD/MM/YYYY',
|
|
percent: '0%',
|
|
};
|
|
|
|
const toCellValue = (value: unknown, format?: string) => {
|
|
if (value === null || value === undefined || value === '') return '';
|
|
if (format === 'currency' || format === 'number') {
|
|
const n = typeof value === 'number' ? value : parseFloat(String(value));
|
|
return Number.isNaN(n) ? String(value) : n;
|
|
}
|
|
if (format === 'percent') {
|
|
const n = typeof value === 'number' ? value : parseFloat(String(value));
|
|
return Number.isNaN(n) ? String(value) : (n > 1 ? n / 100 : n);
|
|
}
|
|
if (format === 'date') {
|
|
const s = String(value);
|
|
const d = new Date(s.length === 10 ? `${s}T12:00:00` : s.replace(' ', 'T'));
|
|
return Number.isNaN(d.getTime()) ? s : d;
|
|
}
|
|
return String(value);
|
|
};
|
|
|
|
export const buildWorkbook = async (opts: ExportOptions) => {
|
|
const mod: unknown = await import('exceljs');
|
|
const ExcelJS = ((mod as { default?: unknown }).default ?? mod) as typeof import('exceljs');
|
|
const wb = new ExcelJS.Workbook();
|
|
const ws = wb.addWorksheet(opts.sheetName.slice(0, 31));
|
|
const lastCol = Math.max(opts.columns.length, 1);
|
|
const lastColLetter = ws.getColumn(lastCol).letter;
|
|
|
|
// Título (marca)
|
|
ws.mergeCells(`A1:${lastColLetter}1`);
|
|
const titleCell = ws.getCell('A1');
|
|
titleCell.value = opts.title;
|
|
titleCell.font = { bold: true, size: 14, color: { argb: 'FF1A1A1A' } };
|
|
titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEDA588' } };
|
|
titleCell.alignment = { vertical: 'middle' };
|
|
ws.getRow(1).height = 24;
|
|
|
|
let rowIdx = 2;
|
|
if (opts.subtitle) {
|
|
ws.mergeCells(`A2:${lastColLetter}2`);
|
|
const sub = ws.getCell('A2');
|
|
sub.value = opts.subtitle;
|
|
sub.font = { size: 10, color: { argb: 'FF6B6B6B' } };
|
|
rowIdx = 3;
|
|
}
|
|
rowIdx += 1; // línea en blanco
|
|
|
|
// Encabezados
|
|
const headerRow = ws.getRow(rowIdx);
|
|
opts.columns.forEach((col, i) => {
|
|
const cell = headerRow.getCell(i + 1);
|
|
cell.value = col.header;
|
|
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1A1A1A' } };
|
|
cell.border = { bottom: { style: 'thin', color: { argb: 'FFD2D6DE' } } };
|
|
cell.alignment = { vertical: 'middle' };
|
|
});
|
|
headerRow.height = 18;
|
|
rowIdx += 1;
|
|
|
|
// Datos
|
|
for (const row of opts.rows) {
|
|
const r = ws.getRow(rowIdx);
|
|
opts.columns.forEach((col, i) => {
|
|
const cell = r.getCell(i + 1);
|
|
cell.value = toCellValue(row[col.key], col.format) as never;
|
|
if (col.format && NUM_FORMATS[col.format]) {
|
|
cell.numFmt = NUM_FORMATS[col.format];
|
|
}
|
|
});
|
|
rowIdx += 1;
|
|
}
|
|
|
|
// Totales
|
|
if (opts.totals) {
|
|
const r = ws.getRow(rowIdx);
|
|
opts.columns.forEach((col, i) => {
|
|
const cell = r.getCell(i + 1);
|
|
const v = opts.totals![col.key];
|
|
cell.value = (i === 0 && (v === undefined || v === '')) ? 'Totales' : toCellValue(v, col.format) as never;
|
|
cell.font = { bold: true };
|
|
cell.border = { top: { style: 'thin', color: { argb: 'FF1A1A1A' } } };
|
|
if (col.format && NUM_FORMATS[col.format]) {
|
|
cell.numFmt = NUM_FORMATS[col.format];
|
|
}
|
|
});
|
|
}
|
|
|
|
// Anchos: explícito o estimado por contenido (tope 50)
|
|
opts.columns.forEach((col, i) => {
|
|
if (col.width) {
|
|
ws.getColumn(i + 1).width = col.width;
|
|
return;
|
|
}
|
|
let max = col.header.length;
|
|
for (const row of opts.rows.slice(0, 500)) {
|
|
const v = row[col.key];
|
|
if (v !== null && v !== undefined) max = Math.max(max, String(v).length);
|
|
}
|
|
if (opts.totals && opts.totals[col.key] !== undefined) {
|
|
max = Math.max(max, String(opts.totals[col.key]).length);
|
|
}
|
|
ws.getColumn(i + 1).width = Math.min(max + 3, 50);
|
|
});
|
|
|
|
return wb;
|
|
};
|
|
|
|
export const exportToExcel = async (opts: ExportOptions): Promise<void> => {
|
|
const wb = await buildWorkbook(opts);
|
|
const buffer = await wb.xlsx.writeBuffer();
|
|
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = opts.filename.endsWith('.xlsx') ? opts.filename : `${opts.filename}.xlsx`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
};
|