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:
@@ -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);
|
||||
|
||||
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);
|
||||
};
|
||||
29
frontend/src/lib/menu-items.ts
Normal file
29
frontend/src/lib/menu-items.ts
Normal 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' },
|
||||
];
|
||||
143
frontend/src/lib/quicklinks.tsx
Normal file
143
frontend/src/lib/quicklinks.tsx
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user