Mejoras integrales: visitas, POS, WACRM, reportes, temas y módulos legacy
- 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
This commit is contained in:
148
frontend/src/lib/exporter.ts
Normal file
148
frontend/src/lib/exporter.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
Reference in New Issue
Block a user