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:
2026-08-13 23:30:08 +00:00
parent a718592291
commit 01f6007e30
109 changed files with 19067 additions and 931 deletions

View File

@@ -17,17 +17,28 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
'/': ['admin', 'recepcion', 'medico', 'lectura'],
'/agenda': ['admin', 'recepcion', 'medico'],
'/pacientes': ['admin', 'recepcion', 'medico', 'lectura'],
'/pos': ['admin', 'recepcion'],
'/visitas': ['admin', 'recepcion', 'medico'],
'/consultas': ['admin', 'recepcion', 'medico'],
'/expedientes': ['admin', 'recepcion', 'medico'],
'/medicos': ['admin', 'recepcion'],
'/servicios': ['admin', 'recepcion', 'medico', 'lectura'],
'/productos': ['admin', 'recepcion', 'lectura'],
'/productos': ['admin', 'recepcion', 'lectura'], // deprecated: redirige a /inventario
'/ventas': ['admin', 'recepcion'],
'/pagos': ['admin', 'recepcion'],
'/monedero': ['admin', 'recepcion', 'lectura'],
'/inventario': ['admin', 'recepcion', 'lectura'],
'/cortes': ['admin', 'recepcion'],
'/reportes': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=adeudos': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=horas-agenda': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=paquetes': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=vendedores': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura'],
'/configuracion': ['admin'],
'/usuarios': ['admin'],
'/cumpleanos': ['admin', 'recepcion'],
'/wacrm/messages': ['admin', 'recepcion'],
'/wacrm/leads': ['admin', 'recepcion'],
@@ -109,6 +120,10 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
const canSee = (href: string): boolean => {
if (!user) return false;
// Permisos personalizados: lista no vacía manda sobre los defaults por rol
if (user.allowed_menus && user.allowed_menus.length > 0) {
return user.allowed_menus.includes(href);
}
const allowed = MENU_ROLES[href];
if (!allowed) return true;
return allowed.includes(user.role);

View 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);
};

View File

@@ -0,0 +1,29 @@
// Lista canónica de secciones del sistema (para permisos de menú por usuario)
export const MENU_ITEMS: { label: string; href: string }[] = [
{ label: 'Dashboard', href: '/' },
{ label: 'Agenda', href: '/agenda' },
{ label: 'Pacientes', href: '/pacientes' },
{ label: 'Visitas', href: '/visitas' },
{ label: 'Últimas Visitas', href: '/expedientes' },
{ label: 'Médicos', href: '/medicos' },
{ label: 'Servicios', href: '/servicios' },
{ label: 'Punto de Venta', href: '/pos' },
{ label: 'Ventas', href: '/ventas' },
{ label: 'Pagos', href: '/pagos' },
{ label: 'Monedero', href: '/monedero' },
{ label: 'Inventario', href: '/inventario' },
{ label: 'Cortes de Caja', href: '/cortes' },
{ label: 'Reportes', href: '/reportes' },
{ label: 'Comisiones', href: '/reportes?tab=comisiones' },
{ label: 'Adeudos', href: '/reportes?tab=adeudos' },
{ label: 'Horas agenda', href: '/reportes?tab=horas-agenda' },
{ label: 'Paquetes', href: '/reportes?tab=paquetes' },
{ label: 'Vendedores', href: '/reportes?tab=vendedores' },
{ label: 'Concentrado', href: '/reportes?tab=concentrado' },
{ label: 'Recomendaciones', href: '/reportes?tab=recomendaciones' },
{ label: 'Exportar', href: '/reportes?tab=exportar' },
{ label: 'Cumpleañeros', href: '/cumpleanos' },
{ label: 'Configuración', href: '/configuracion' },
{ label: 'Mensajes WACRM', href: '/wacrm/messages' },
{ label: 'Leads WACRM', href: '/wacrm/leads' },
];

View File

@@ -0,0 +1,143 @@
import type { FC, ReactNode } from 'react';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { useAuth } from './auth';
const LINKS_PREFIX = 'skeen-quicklinks:';
const MODE_PREFIX = 'skeen-quicklinks-mode:';
// Accesos iniciales sugeridos la primera vez (el usuario puede quitarlos)
const DEFAULT_LINKS = ['/agenda', '/pacientes', '/ventas'];
export type QuickLinksMode = 'menu' | 'right' | 'bottom';
export const QUICK_LINKS_MODE_LABELS: Record<QuickLinksMode, string> = {
menu: 'En el menú lateral',
right: 'Barra de iconos a la derecha',
bottom: 'Barra de iconos abajo',
};
interface QuickLinksValue {
links: string[];
mode: QuickLinksMode;
editing: boolean;
setEditing: (v: boolean) => void;
setMode: (m: QuickLinksMode) => void;
toggle: (href: string) => void;
remove: (href: string) => void;
move: (href: string, dir: -1 | 1) => void;
isPinned: (href: string) => boolean;
}
const QuickLinksContext = createContext<QuickLinksValue | null>(null);
const readJson = <T,>(key: string, fallback: T): T => {
try {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
};
const writeJson = (key: string, value: unknown) => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// localStorage lleno o no disponible: se mantiene solo en memoria
}
};
const readLinks = (key: string): string[] => {
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null;
if (raw === null) {
writeJson(key, DEFAULT_LINKS);
return DEFAULT_LINKS;
}
const parsed = readJson<unknown>(key, []);
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [];
};
const readMode = (key: string): QuickLinksMode => {
const m = readJson<string>(key, 'menu');
return m === 'right' || m === 'bottom' || m === 'menu' ? m : 'menu';
};
/**
* Accesos rápidos personalizables, persistidos en localStorage por usuario.
* Compartido por SkeenLayout, HomeNestLayout y QuickLinksDock.
*/
export const QuickLinksProvider: FC<{ children: ReactNode }> = ({ children }) => {
const { user } = useAuth();
const linksKey = `${LINKS_PREFIX}${user?.id ?? 'anon'}`;
const modeKey = `${MODE_PREFIX}${user?.id ?? 'anon'}`;
const [links, setLinks] = useState<string[]>(() => readLinks(linksKey));
const [mode, setModeState] = useState<QuickLinksMode>(() => readMode(modeKey));
const [editing, setEditing] = useState(false);
useEffect(() => {
setLinks(readLinks(linksKey));
setModeState(readMode(modeKey));
setEditing(false);
}, [linksKey, modeKey]);
const persistLinks = useCallback(
(updater: (prev: string[]) => string[]) => {
setLinks((prev) => {
const next = updater(prev);
writeJson(linksKey, next);
return next;
});
},
[linksKey]
);
const setMode = useCallback(
(m: QuickLinksMode) => {
setModeState(m);
writeJson(modeKey, m);
},
[modeKey]
);
const toggle = useCallback(
(href: string) =>
persistLinks((prev) => (prev.includes(href) ? prev.filter((l) => l !== href) : [...prev, href])),
[persistLinks]
);
const remove = useCallback(
(href: string) => persistLinks((prev) => prev.filter((l) => l !== href)),
[persistLinks]
);
const move = useCallback(
(href: string, dir: -1 | 1) =>
persistLinks((prev) => {
const i = prev.indexOf(href);
const j = i + dir;
if (i < 0 || j < 0 || j >= prev.length) return prev;
const next = [...prev];
[next[i], next[j]] = [next[j], next[i]];
return next;
}),
[persistLinks]
);
const isPinned = useCallback((href: string) => links.includes(href), [links]);
return (
<QuickLinksContext.Provider
value={{ links, mode, editing, setEditing, setMode, toggle, remove, move, isPinned }}
>
{children}
</QuickLinksContext.Provider>
);
};
export function useQuickLinks(): QuickLinksValue {
const ctx = useContext(QuickLinksContext);
if (!ctx) throw new Error('useQuickLinks debe usarse dentro de QuickLinksProvider');
return ctx;
}

View File

@@ -1,6 +1,6 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
type Theme = 'skeen' | 'homenest';
export type Theme = 'skeen' | 'homenest' | 'clasico';
interface ThemeContextValue {
theme: Theme;
@@ -11,11 +11,12 @@ interface ThemeContextValue {
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
const STORAGE_KEY = 'skeen-theme';
const THEME_ORDER: Theme[] = ['skeen', 'homenest', 'clasico'];
function getInitialTheme(): Theme {
if (typeof window === 'undefined') return 'skeen';
const stored = window.localStorage.getItem(STORAGE_KEY) as Theme | null;
if (stored === 'skeen' || stored === 'homenest') return stored;
if (stored === 'skeen' || stored === 'homenest' || stored === 'clasico') return stored;
return 'skeen';
}
@@ -30,7 +31,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}
};
const toggleTheme = () => setTheme(theme === 'skeen' ? 'homenest' : 'skeen');
const toggleTheme = () => {
const idx = THEME_ORDER.indexOf(theme);
setTheme(THEME_ORDER[(idx + 1) % THEME_ORDER.length]);
};
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);