Oleada UX 6: Reportes agrupados en familias con rango global persistente y favoritos; Configuracion con auditoria de cambios y simulador de comisiones

This commit is contained in:
2026-09-17 22:06:11 +00:00
parent cd945df6a5
commit 31c92ac419
21 changed files with 1325 additions and 137 deletions

View File

@@ -0,0 +1,209 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { History, Search, X } from 'lucide-react';
import { Card, Button, Input, Select, EmptyState, Skeleton, toast } from './ui';
import { odooApi, type ConfigAuditRow } from '../services/odoo';
const MODULOS = [
{ value: '', label: 'Todos los módulos' },
{ value: 'comisiones', label: 'Comisiones' },
{ value: 'tipo_cambio', label: 'Tipo de cambio' },
{ value: 'metas', label: 'Metas' },
{ value: 'horarios', label: 'Horarios' },
{ value: 'roles', label: 'Roles y permisos' },
{ value: 'usuarios', label: 'Usuarios' },
{ value: 'catalogos', label: 'Catálogos' },
];
const PAGE_SIZE = 25;
const fmtFecha = (iso: string | null) => {
if (!iso) return '—';
const [d, h] = iso.split(' ');
const [y, m, day] = d.split('-');
return `${day}/${m}/${y} ${h || ''}`.trim();
};
const recorta = (v: string, n = 60) => (v && v.length > n ? `${v.slice(0, n)}…` : v || '—');
/** Bitácora de cambios de configuración: quién cambió qué y con qué valores. */
const AuditoriaPanel: FC = () => {
const [rows, setRows] = useState<ConfigAuditRow[]>([]);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [modulo, setModulo] = useState('');
const [usuario, setUsuario] = useState('');
const [entidad, setEntidad] = useState('');
const [desde, setDesde] = useState('');
const [hasta, setHasta] = useState('');
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getConfigAudit({
module: modulo || undefined,
user: usuario || undefined,
entity: entidad || undefined,
date_from: desde || undefined,
date_to: hasta || undefined,
page,
page_size: PAGE_SIZE,
});
if (res.status === 'success') {
setRows(res.registros);
setTotal(res.total);
setTotalPages(res.total_pages || 1);
}
} catch {
toast.error('No se pudo cargar la auditoría');
} finally {
setLoading(false);
}
}, [modulo, usuario, entidad, desde, hasta, page]);
useEffect(() => {
const t = setTimeout(load, 250);
return () => clearTimeout(t);
}, [load]);
useEffect(() => {
setPage(1);
}, [modulo, usuario, entidad, desde, hasta]);
const hayFiltros = modulo || usuario || entidad || desde || hasta;
const limpiar = () => {
setModulo('');
setUsuario('');
setEntidad('');
setDesde('');
setHasta('');
};
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-end gap-3 flex-wrap">
<Select
label="Módulo"
options={MODULOS}
value={modulo}
onChange={(e) => setModulo(e.target.value)}
className="sm:max-w-[190px]"
/>
<div className="relative sm:max-w-[200px] w-full">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
label="Usuario"
placeholder="login o nombre"
value={usuario}
onChange={(e) => setUsuario(e.target.value)}
className="pl-9"
/>
</div>
<Input
label="Entidad"
placeholder="médico, usuario, TC..."
value={entidad}
onChange={(e) => setEntidad(e.target.value)}
className="sm:max-w-[190px]"
/>
<Input
label="Desde"
type="date"
value={desde}
onChange={(e) => setDesde(e.target.value)}
className="sm:max-w-[160px]"
/>
<Input
label="Hasta"
type="date"
value={hasta}
onChange={(e) => setHasta(e.target.value)}
className="sm:max-w-[160px]"
/>
{hayFiltros && (
<Button variant="ghost" size="sm" onClick={limpiar} className="sm:mb-0.5">
<X size={14} className="mr-1" /> Limpiar
</Button>
)}
</div>
<Card>
<Card.Body>
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
<h3 className="font-heading text-lg text-theme-heading inline-flex items-center gap-2">
<History size={18} /> Cambios de configuración
</h3>
<p className="text-xs text-theme-muted">
{total} {total === 1 ? 'registro' : 'registros'} · página {page} de {totalPages || 1}
</p>
</div>
{loading ? (
<Skeleton count={6} className="h-10 w-full" />
) : rows.length === 0 ? (
<EmptyState
title="Sin cambios registrados"
subtitle={hayFiltros ? 'Ningún registro coincide con los filtros.' : 'Aún no se ha modificado configuración sensible.'}
icon={<History size={28} />}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-3 py-2 whitespace-nowrap">Fecha</th>
<th className="text-left px-3 py-2">Usuario</th>
<th className="text-left px-3 py-2">Módulo</th>
<th className="text-left px-3 py-2">Entidad</th>
<th className="text-left px-3 py-2">Campo</th>
<th className="text-left px-3 py-2">Antes</th>
<th className="text-left px-3 py-2">Después</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{rows.map((r) => (
<tr key={r.id} className="hover:bg-theme-bg align-top">
<td className="px-3 py-2 whitespace-nowrap text-theme-muted">{fmtFecha(r.date)}</td>
<td className="px-3 py-2">
<p className="font-medium text-theme-heading">{r.user_name || r.user_login || '—'}</p>
<p className="text-[11px] text-theme-muted">{r.user_login}{r.user_role ? ` · ${r.user_role}` : ''}</p>
</td>
<td className="px-3 py-2">
<span className="inline-flex rounded-full bg-theme-accent-bg px-2 py-0.5 text-[11px] font-medium text-theme-heading">
{r.module_label}
</span>
</td>
<td className="px-3 py-2">
<p className="text-theme-heading">{r.entity_label || r.entity || '—'}</p>
{r.entity_label && r.entity && <p className="text-[11px] text-theme-muted">{r.entity}</p>}
</td>
<td className="px-3 py-2 text-theme-muted">{r.field || '—'}</td>
<td className="px-3 py-2 text-theme-muted" title={r.old_value}>{recorta(r.old_value)}</td>
<td className="px-3 py-2 text-theme-heading font-medium" title={r.new_value}>{recorta(r.new_value)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<div className="flex items-center justify-end gap-2 mt-4 pt-3 border-t border-theme-border">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
Anterior
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Siguiente
</Button>
</div>
)}
</Card.Body>
</Card>
</div>
);
};
export default AuditoriaPanel;

View File

@@ -0,0 +1,216 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Calculator, RefreshCw } from 'lucide-react';
import { Button, Input, Modal, Skeleton, toast } from './ui';
import { odooApi, type ImpactSimulation } from '../services/odoo';
const money = (v: number | null | undefined) =>
(v ?? 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
const usd = (v: number | null | undefined) =>
v == null ? '—' : `$${v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} USD`;
const ORIGEN: Record<string, string> = {
propio: 'propio',
global: 'global',
propuesto: 'propuesto',
};
const primerDiaMesPasado = () => {
const d = new Date();
const ref = new Date(d.getFullYear(), d.getMonth() - 1, 1);
return `${ref.getFullYear()}-${String(ref.getMonth() + 1).padStart(2, '0')}-01`;
};
const ultimoDiaMesPasado = () => {
const d = new Date();
const ref = new Date(d.getFullYear(), d.getMonth(), 0);
return `${ref.getFullYear()}-${String(ref.getMonth() + 1).padStart(2, '0')}-${String(ref.getDate()).padStart(2, '0')}`;
};
interface Props {
isOpen: boolean;
onClose: () => void;
/** % global configurado, para precargar la prueba. */
comisionGlobal: number;
}
/**
* "Ver efecto": simula, con las ventas atribuidas reales del periodo, cuánto
* ganaría cada médico con los valores configurados — y permite probar un % de
* comisión o un tipo de cambio candidatos sin guardarlos.
*/
const ImpactoModal: FC<Props> = ({ isOpen, onClose, comisionGlobal }) => {
const [desde, setDesde] = useState(primerDiaMesPasado());
const [hasta, setHasta] = useState(ultimoDiaMesPasado());
const [pct, setPct] = useState('');
const [rate, setRate] = useState('');
const [data, setData] = useState<ImpactSimulation | null>(null);
const [loading, setLoading] = useState(false);
const cargar = useCallback(async (conPropuestos: boolean) => {
try {
setLoading(true);
const res = await odooApi.getImpactSimulation({
date_from: desde || undefined,
date_to: hasta || undefined,
commission_pct: conPropuestos && pct !== '' ? parseFloat(pct) : undefined,
rate: conPropuestos && rate !== '' ? parseFloat(rate) : undefined,
});
if (res.status === 'success') setData(res);
else toast.error(res.message || 'No se pudo calcular el efecto');
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo calcular el efecto');
} finally {
setLoading(false);
}
}, [desde, hasta, pct, rate]);
useEffect(() => {
if (!isOpen) return;
setPct('');
setRate('');
cargar(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
const probar = () => {
if (pct !== '' && (Number.isNaN(parseFloat(pct)) || parseFloat(pct) < 0 || parseFloat(pct) > 100)) {
toast.error('El porcentaje debe estar entre 0 y 100');
return;
}
cargar(true);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Ver efecto de la configuración"
maxWidth="2xl"
footer={
<>
<Button variant="outline" onClick={onClose}>Cerrar</Button>
<Button onClick={probar} loading={loading}>
<Calculator size={14} className="mr-1.5" />
Probar valores
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-xs text-theme-muted">
Calculado con las <span className="font-medium text-theme-heading">ventas atribuidas reales</span> del periodo
(líneas recetadas por cada médico). No guarda nada: solo simula.
</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Input label="Desde" type="date" value={desde} onChange={(e) => setDesde(e.target.value)} />
<Input label="Hasta" type="date" value={hasta} onChange={(e) => setHasta(e.target.value)} />
<Input
label="% comisión a probar"
type="number"
step="0.5"
min="0"
max="100"
placeholder={`${comisionGlobal}`}
value={pct}
onChange={(e) => setPct(e.target.value)}
/>
<Input
label="TC a probar"
type="number"
step="0.01"
min="0"
placeholder={data?.rate_actual != null ? String(data.rate_actual) : '17.25'}
value={rate}
onChange={(e) => setRate(e.target.value)}
/>
</div>
<div className="flex flex-wrap gap-3 text-xs">
<Button variant="ghost" size="sm" onClick={() => cargar(false)} disabled={loading}>
<RefreshCw size={13} className="mr-1" /> Recalcular con lo configurado
</Button>
<span className="text-theme-muted">
% global configurado: <span className="font-medium text-theme-heading">{data?.commission_pct_global ?? comisionGlobal}%</span>
</span>
{data?.commission_pct_propuesto != null && (
<span className="text-theme-muted">
simulando con <span className="font-medium text-theme-heading">{data.commission_pct_propuesto}%</span> para todos
</span>
)}
</div>
{loading ? (
<Skeleton count={5} className="h-10 w-full" />
) : !data ? (
<p className="text-sm text-theme-muted">Sin datos.</p>
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted">Ventas del periodo</p>
<p className="text-base font-semibold text-theme-heading">{data.ventas}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted">Base comisionable</p>
<p className="text-base font-semibold text-theme-heading">{money(data.total_base)}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted">Comisión total</p>
<p className="text-base font-semibold text-theme-heading">{money(data.total_commission)}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted">Base en USD (TC {data.rate ?? '—'})</p>
<p className="text-base font-semibold text-theme-heading">{usd(data.total_usd)}</p>
</div>
</div>
{data.doctores.length === 0 ? (
<p className="text-sm text-theme-muted">
No hay ventas con artículo recetado en ese periodo, así que la comisión sería cero.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-3 py-2">Médico</th>
<th className="text-right px-3 py-2">Base</th>
<th className="text-right px-3 py-2">% aplicado</th>
<th className="text-left px-3 py-2">Origen</th>
<th className="text-right px-3 py-2">Comisión</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{data.doctores.map((d) => (
<tr key={d.doctor_id} className="hover:bg-theme-bg">
<td className="px-3 py-2">
<p className="font-medium text-theme-heading">{d.doctor}</p>
<p className="text-[11px] text-theme-muted">{d.job_title || '—'}</p>
</td>
<td className="px-3 py-2 text-right text-theme-muted">{money(d.base)}</td>
<td className="px-3 py-2 text-right text-theme-heading">{d.commission_pct_efectivo}%</td>
<td className="px-3 py-2 text-theme-muted">{ORIGEN[d.origen_pct] || d.origen_pct}</td>
<td className="px-3 py-2 text-right font-semibold text-theme-heading">{money(d.commission)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="text-[11px] text-theme-muted">
El % aplicado es el del médico cuando lo tiene; si está en 0 se usa el % global configurado.
El reporte de Comisiones, en cambio, sólo usa el % propio de cada médico.
</p>
</>
)}
</div>
</Modal>
);
};
export default ImpactoModal;

View File

@@ -5,20 +5,15 @@ import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton'; import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter'; import { exportToExcel } from '../../lib/exporter';
import { odooApi, type ComisionRow } from '../../services/odoo'; import { odooApi, type ComisionRow } from '../../services/odoo';
import { useReportRange } from '../../lib/reportRange';
const formatCurrency = (value: number) => const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value); new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
const inicioDeMes = () => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
};
const hoy = () => new Date().toISOString().split('T')[0];
const ComisionesReport: FC = () => { const ComisionesReport: FC = () => {
const [start, setStart] = useState(inicioDeMes()); // Rango global de Reportes; los campos Inicio/Fin fijan uno propio
const [end, setEnd] = useState(hoy()); const { start, end, setStart, setEnd } = useReportRange('comisiones');
const [rows, setRows] = useState<ComisionRow[]>([]); const [rows, setRows] = useState<ComisionRow[]>([]);
const [totales, setTotales] = useState({ vendido: 0, comision: 0 }); const [totales, setTotales] = useState({ vendido: 0, comision: 0 });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -79,6 +74,7 @@ const ComisionesReport: FC = () => {
<div className="flex flex-col sm:flex-row gap-3 items-end"> <div className="flex flex-col sm:flex-row gap-3 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" /> <Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" /> <Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<ExportButton onClick={exportar} loading={exporting} /> <ExportButton onClick={exportar} loading={exporting} />
</div> </div>
<p className="text-sm text-theme-muted"> <p className="text-sm text-theme-muted">

View File

@@ -6,6 +6,7 @@ import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter'; import { exportToExcel } from '../../lib/exporter';
import { odooApi, type ConcentradoReport as ConcentradoData } from '../../services/odoo'; import { odooApi, type ConcentradoReport as ConcentradoData } from '../../services/odoo';
import type { BadgeVariant } from '../ui/Badge'; import type { BadgeVariant } from '../ui/Badge';
import { useReportRange } from '../../lib/reportRange';
const formatCurrency = (value: number) => const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value); new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
@@ -26,12 +27,6 @@ const stateBadge = (state: string): BadgeVariant =>
: state === 'cancelled' || state === 'no_show' || state === 'cancelada' ? 'danger' : state === 'cancelled' || state === 'no_show' || state === 'cancelada' ? 'danger'
: state === 'pending' || state === 'en_curso' ? 'warning' : 'info'; : state === 'pending' || state === 'en_curso' ? 'warning' : 'info';
const inicioDeMes = () => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
};
const hoy = () => new Date().toISOString().split('T')[0];
const Kpi: FC<{ label: string; value: React.ReactNode; rose?: boolean }> = ({ label, value, rose }) => ( const Kpi: FC<{ label: string; value: React.ReactNode; rose?: boolean }> = ({ label, value, rose }) => (
<Card className="p-4 bg-theme-bg border-0"> <Card className="p-4 bg-theme-bg border-0">
@@ -41,8 +36,8 @@ const Kpi: FC<{ label: string; value: React.ReactNode; rose?: boolean }> = ({ la
); );
const ConcentradoReport: FC = () => { const ConcentradoReport: FC = () => {
const [start, setStart] = useState(inicioDeMes()); // Rango global de Reportes; los campos Inicio/Fin fijan uno propio
const [end, setEnd] = useState(hoy()); const { start, end, setStart, setEnd } = useReportRange('concentrado');
const [data, setData] = useState<ConcentradoData | null>(null); const [data, setData] = useState<ConcentradoData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -111,6 +106,7 @@ const ConcentradoReport: FC = () => {
<div className="flex flex-col sm:flex-row gap-3 items-end"> <div className="flex flex-col sm:flex-row gap-3 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" /> <Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" /> <Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<div className="sm:ml-auto"> <div className="sm:ml-auto">
<ExportButton onClick={exportar} loading={exporting} /> <ExportButton onClick={exportar} loading={exporting} />
</div> </div>

View File

@@ -29,11 +29,15 @@ const metodoLabels: Record<string, string> = {
stripe: 'Stripe', mercadopago: 'MercadoPago', stripe: 'Stripe', mercadopago: 'MercadoPago',
}; };
const hoy = () => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
const moveTypeLabels: Record<string, string> = { const moveTypeLabels: Record<string, string> = {
compra: 'Compra', venta: 'Venta', baja: 'Baja', ajuste: 'Ajuste', compra: 'Compra', venta: 'Venta', baja: 'Baja', ajuste: 'Ajuste',
}; };
const hoy = () => new Date().toISOString().split('T')[0];
const DailyReport: FC = () => { const DailyReport: FC = () => {
const [fecha, setFecha] = useState(hoy()); const [fecha, setFecha] = useState(hoy());

View File

@@ -5,20 +5,15 @@ import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton'; import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter'; import { exportToExcel } from '../../lib/exporter';
import { odooApi, type DevolucionRow } from '../../services/odoo'; import { odooApi, type DevolucionRow } from '../../services/odoo';
import { useReportRange } from '../../lib/reportRange';
const formatCurrency = (value: number) => const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value); new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
const inicioDeMes = () => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
};
const hoy = () => new Date().toISOString().split('T')[0];
const DevolucionesReport: FC = () => { const DevolucionesReport: FC = () => {
const [start, setStart] = useState(inicioDeMes()); // Rango global de Reportes; los campos Inicio/Fin fijan uno propio
const [end, setEnd] = useState(hoy()); const { start, end, setStart, setEnd } = useReportRange('devoluciones');
const [devoluciones, setDevoluciones] = useState<DevolucionRow[]>([]); const [devoluciones, setDevoluciones] = useState<DevolucionRow[]>([]);
const [totalDevuelto, setTotalDevuelto] = useState(0); const [totalDevuelto, setTotalDevuelto] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -78,6 +73,7 @@ const DevolucionesReport: FC = () => {
<div className="flex flex-col sm:flex-row gap-3 items-end"> <div className="flex flex-col sm:flex-row gap-3 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" /> <Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" /> <Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<ExportButton onClick={exportar} loading={exporting} /> <ExportButton onClick={exportar} loading={exporting} />
</div> </div>
<p className="text-sm text-theme-muted"> <p className="text-sm text-theme-muted">

View File

@@ -15,6 +15,7 @@ import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter'; import { exportToExcel } from '../../lib/exporter';
import { odooApi, type HoraAgendaRow } from '../../services/odoo'; import { odooApi, type HoraAgendaRow } from '../../services/odoo';
import type { BadgeVariant } from '../ui/Badge'; import type { BadgeVariant } from '../ui/Badge';
import { useReportRange } from '../../lib/reportRange';
const citaStateLabels: Record<string, string> = { const citaStateLabels: Record<string, string> = {
pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó', pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó',
@@ -30,11 +31,10 @@ const citaStateBadge = (state: string): BadgeVariant => {
} }
}; };
const hoy = () => new Date().toISOString().split('T')[0];
const HorasAgendaReport: FC = () => { const HorasAgendaReport: FC = () => {
const [start, setStart] = useState(hoy()); // Rango global de Reportes; los campos Inicio/Fin fijan uno propio
const [end, setEnd] = useState(hoy()); const { start, end, setStart, setEnd } = useReportRange('horas-agenda');
const [horas, setHoras] = useState<HoraAgendaRow[]>([]); const [horas, setHoras] = useState<HoraAgendaRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -94,6 +94,7 @@ const HorasAgendaReport: FC = () => {
<div className="flex flex-col sm:flex-row sm:items-end gap-3"> <div className="flex flex-col sm:flex-row sm:items-end gap-3">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" /> <Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" /> <Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<ExportButton onClick={exportar} loading={exporting} /> <ExportButton onClick={exportar} loading={exporting} />
<p className="text-sm text-theme-muted sm:ml-auto">{total} citas en el periodo</p> <p className="text-sm text-theme-muted sm:ml-auto">{total} citas en el periodo</p>
</div> </div>

View File

@@ -14,6 +14,7 @@ import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton'; import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter'; import { exportToExcel } from '../../lib/exporter';
import { odooApi } from '../../services/odoo'; import { odooApi } from '../../services/odoo';
import { useReportRange } from '../../lib/reportRange';
const formatCurrency = (value: number) => const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value); new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
@@ -23,12 +24,6 @@ const metodoLabels: Record<string, string> = {
stripe: 'Stripe', mercadopago: 'MercadoPago', stripe: 'Stripe', mercadopago: 'MercadoPago',
}; };
const inicioDeMes = () => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
};
const hoy = () => new Date().toISOString().split('T')[0];
interface SalesData { interface SalesData {
total_sales: number; total_sales: number;
@@ -39,8 +34,8 @@ interface SalesData {
} }
const IngresosReport: FC = () => { const IngresosReport: FC = () => {
const [start, setStart] = useState(inicioDeMes()); // Rango global de Reportes; los campos Inicio/Fin fijan uno propio
const [end, setEnd] = useState(hoy()); const { start, end, setStart, setEnd } = useReportRange('ingresos');
const [sales, setSales] = useState<SalesData | null>(null); const [sales, setSales] = useState<SalesData | null>(null);
const [byMethod, setByMethod] = useState<Record<string, number>>({}); const [byMethod, setByMethod] = useState<Record<string, number>>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -117,6 +112,7 @@ const IngresosReport: FC = () => {
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 items-end"> <div className="flex flex-col sm:flex-row gap-3 sm:gap-4 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[180px]" /> <Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[180px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[180px]" /> <Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[180px]" />
<div className="sm:ml-auto"> <div className="sm:ml-auto">
<ExportButton onClick={exportar} loading={exporting} /> <ExportButton onClick={exportar} loading={exporting} />
</div> </div>

View File

@@ -14,15 +14,10 @@ import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton'; import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter'; import { exportToExcel } from '../../lib/exporter';
import { odooApi, type KpisReport as KpisData } from '../../services/odoo'; import { odooApi, type KpisReport as KpisData } from '../../services/odoo';
import { useReportRange } from '../../lib/reportRange';
const pct = (v: number) => `${Math.round(v * 100)}%`; const pct = (v: number) => `${Math.round(v * 100)}%`;
const inicioDeMes = () => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
};
const hoy = () => new Date().toISOString().split('T')[0];
const Kpi: FC<{ label: string; value: React.ReactNode; icon: React.ReactNode; rose?: boolean }> = ({ label, value, icon, rose }) => ( const Kpi: FC<{ label: string; value: React.ReactNode; icon: React.ReactNode; rose?: boolean }> = ({ label, value, icon, rose }) => (
<Card className="p-4 bg-theme-bg border-0"> <Card className="p-4 bg-theme-bg border-0">
@@ -35,8 +30,8 @@ const Kpi: FC<{ label: string; value: React.ReactNode; icon: React.ReactNode; ro
); );
const KpisReport: FC = () => { const KpisReport: FC = () => {
const [start, setStart] = useState(inicioDeMes()); // Rango global de Reportes; los campos Inicio/Fin fijan uno propio
const [end, setEnd] = useState(hoy()); const { start, end, setStart, setEnd } = useReportRange('kpis');
const [data, setData] = useState<KpisData | null>(null); const [data, setData] = useState<KpisData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
@@ -102,6 +97,7 @@ const KpisReport: FC = () => {
<div className="flex flex-col sm:flex-row gap-3 items-end"> <div className="flex flex-col sm:flex-row gap-3 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" /> <Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" /> <Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<div className="sm:ml-auto"> <div className="sm:ml-auto">
<ExportButton onClick={exportar} loading={exporting} /> <ExportButton onClick={exportar} loading={exporting} />
</div> </div>

View File

@@ -5,20 +5,15 @@ import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton'; import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter'; import { exportToExcel } from '../../lib/exporter';
import { odooApi, type PagoClienteRow } from '../../services/odoo'; import { odooApi, type PagoClienteRow } from '../../services/odoo';
import { useReportRange } from '../../lib/reportRange';
const formatCurrency = (value: number) => const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value); new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
const inicioDeMes = () => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
};
const hoy = () => new Date().toISOString().split('T')[0];
const PagosClientesReport: FC = () => { const PagosClientesReport: FC = () => {
const [start, setStart] = useState(inicioDeMes()); // Rango global de Reportes; los campos Inicio/Fin fijan uno propio
const [end, setEnd] = useState(hoy()); const { start, end, setStart, setEnd } = useReportRange('pagos-clientes');
const [clientes, setClientes] = useState<PagoClienteRow[]>([]); const [clientes, setClientes] = useState<PagoClienteRow[]>([]);
const [totalCobrado, setTotalCobrado] = useState(0); const [totalCobrado, setTotalCobrado] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -75,6 +70,7 @@ const PagosClientesReport: FC = () => {
<div className="flex flex-col sm:flex-row gap-3 items-end"> <div className="flex flex-col sm:flex-row gap-3 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" /> <Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" /> <Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<ExportButton onClick={exportar} loading={exporting} /> <ExportButton onClick={exportar} loading={exporting} />
</div> </div>
<p className="text-sm text-theme-muted"> <p className="text-sm text-theme-muted">

View File

@@ -5,6 +5,7 @@ import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton'; import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter'; import { exportToExcel } from '../../lib/exporter';
import { odooApi, type PagoServicioRow } from '../../services/odoo'; import { odooApi, type PagoServicioRow } from '../../services/odoo';
import { useReportRange } from '../../lib/reportRange';
const formatCurrency = (value: number) => const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value); new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
@@ -14,16 +15,10 @@ const metodoLabels: Record<string, string> = {
stripe: 'Stripe', mercadopago: 'MercadoPago', stripe: 'Stripe', mercadopago: 'MercadoPago',
}; };
const inicioDeMes = () => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
};
const hoy = () => new Date().toISOString().split('T')[0];
const PagosServiciosReport: FC = () => { const PagosServiciosReport: FC = () => {
const [start, setStart] = useState(inicioDeMes()); // Rango global de Reportes; los campos Inicio/Fin fijan uno propio
const [end, setEnd] = useState(hoy()); const { start, end, setStart, setEnd } = useReportRange('pagos-servicios');
const [servicios, setServicios] = useState<PagoServicioRow[]>([]); const [servicios, setServicios] = useState<PagoServicioRow[]>([]);
const [byMethod, setByMethod] = useState<Record<string, number>>({}); const [byMethod, setByMethod] = useState<Record<string, number>>({});
const [totalCobrado, setTotalCobrado] = useState(0); const [totalCobrado, setTotalCobrado] = useState(0);
@@ -90,6 +85,7 @@ const PagosServiciosReport: FC = () => {
<div className="flex flex-col sm:flex-row gap-3 items-end"> <div className="flex flex-col sm:flex-row gap-3 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" /> <Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" /> <Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<div className="sm:ml-auto"> <div className="sm:ml-auto">
<ExportButton onClick={exportar} loading={exporting} /> <ExportButton onClick={exportar} loading={exporting} />
</div> </div>

View File

@@ -5,20 +5,15 @@ import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton'; import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter'; import { exportToExcel } from '../../lib/exporter';
import { odooApi, type TopClienteRow } from '../../services/odoo'; import { odooApi, type TopClienteRow } from '../../services/odoo';
import { useReportRange } from '../../lib/reportRange';
const formatCurrency = (value: number) => const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value); new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
const inicioDeMes = () => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
};
const hoy = () => new Date().toISOString().split('T')[0];
const TopClientesReport: FC = () => { const TopClientesReport: FC = () => {
const [start, setStart] = useState(inicioDeMes()); // Rango global de Reportes; los campos Inicio/Fin fijan uno propio
const [end, setEnd] = useState(hoy()); const { start, end, setStart, setEnd } = useReportRange('top-clientes');
const [clientes, setClientes] = useState<TopClienteRow[]>([]); const [clientes, setClientes] = useState<TopClienteRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -70,6 +65,7 @@ const TopClientesReport: FC = () => {
<div className="flex flex-col sm:flex-row gap-3 mb-4 items-end"> <div className="flex flex-col sm:flex-row gap-3 mb-4 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" /> <Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" /> <Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<div className="sm:ml-auto"> <div className="sm:ml-auto">
<ExportButton onClick={exportar} loading={exporting} /> <ExportButton onClick={exportar} loading={exporting} />
</div> </div>

View File

@@ -5,20 +5,15 @@ import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton'; import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter'; import { exportToExcel } from '../../lib/exporter';
import { odooApi, type VendedorRow } from '../../services/odoo'; import { odooApi, type VendedorRow } from '../../services/odoo';
import { useReportRange } from '../../lib/reportRange';
const formatCurrency = (value: number) => const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value); new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
const inicioDeMes = () => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
};
const hoy = () => new Date().toISOString().split('T')[0];
const VendedoresReport: FC = () => { const VendedoresReport: FC = () => {
const [start, setStart] = useState(inicioDeMes()); // Rango global de Reportes; los campos Inicio/Fin fijan uno propio
const [end, setEnd] = useState(hoy()); const { start, end, setStart, setEnd } = useReportRange('vendedores');
const [vendedores, setVendedores] = useState<VendedorRow[]>([]); const [vendedores, setVendedores] = useState<VendedorRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -75,6 +70,7 @@ const VendedoresReport: FC = () => {
<div className="flex flex-col sm:flex-row gap-3 mb-4 items-end"> <div className="flex flex-col sm:flex-row gap-3 mb-4 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" /> <Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" /> <Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<div className="sm:ml-auto"> <div className="sm:ml-auto">
<ExportButton onClick={exportar} loading={exporting} /> <ExportButton onClick={exportar} loading={exporting} />
</div> </div>

View File

@@ -0,0 +1,230 @@
import type { FC, ReactNode } from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { CalendarRange, RotateCcw } from 'lucide-react';
import { rangeForPreset, type DateRange } from './dateRange';
import { useAuth } from './auth';
/**
* Rango de fecha global y persistente para el módulo de Reportes.
*
* El control vive en la cabecera de Reportes y alimenta a todas las pestañas.
* Cada reporte puede fijar su propio rango (override) con los campos Inicio/Fin
* que ya tenía; ese ajuste se descarta en cuanto cambia el rango global.
*/
export type ReportPreset = 'hoy' | 'ayer' | 'semana' | 'mes' | 'mes_pasado' | 'anio' | 'custom';
export const REPORT_PRESETS: { value: ReportPreset; label: string }[] = [
{ value: 'hoy', label: 'Hoy' },
{ value: 'ayer', label: 'Ayer' },
{ value: 'semana', label: 'Esta semana' },
{ value: 'mes', label: 'Este mes' },
{ value: 'mes_pasado', label: 'Mes pasado' },
{ value: 'anio', label: 'Este año' },
{ value: 'custom', label: 'Personalizado' },
];
const STORAGE_KEY = 'skeen_reportes_rango';
interface RangePrefs extends DateRange {
preset: ReportPreset;
}
const readPrefs = (): RangePrefs | null => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const p = JSON.parse(raw) as Partial<RangePrefs>;
if (!p || typeof p.start !== 'string' || typeof p.end !== 'string') return null;
const preset = (REPORT_PRESETS.some((x) => x.value === p.preset) ? p.preset : 'custom') as ReportPreset;
return { preset, start: p.start, end: p.end };
} catch {
return null;
}
};
const writePrefs = (prefs: RangePrefs): void => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
// localStorage no disponible: se mantiene solo en memoria
}
};
const hoyISO = () => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
/** Rango de un preset. Los presets que terminan en el futuro se recortan a hoy. */
export const rangeForReportPreset = (preset: ReportPreset): DateRange => {
const hoy = hoyISO();
if (preset === 'anio') {
return { start: `${new Date().getFullYear()}-01-01`, end: hoy };
}
const r = rangeForPreset((preset === 'custom' ? 'hoy' : preset) as 'hoy' | 'ayer' | 'semana' | 'mes' | 'mes_pasado');
return { start: r.start, end: r.end > hoy ? hoy : r.end };
};
interface GlobalRange {
preset: ReportPreset;
start: string;
end: string;
label: string;
/** Cambia cuando el usuario mueve el rango global: invalida los overrides. */
generation: number;
setRange: (preset: ReportPreset, start?: string, end?: string) => void;
/** Rangos propios por reporte (clave = pestaña), válidos hasta que cambia el global. */
overrides: Record<string, DateRange>;
setOverride: (key: string, range: DateRange | null) => void;
}
const GlobalRangeContext = createContext<GlobalRange | null>(null);
export const ReportRangeProvider: FC<{ children: ReactNode }> = ({ children }) => {
const [preset, setPreset] = useState<ReportPreset>(() => readPrefs()?.preset || 'mes');
const [range, setRangeState] = useState<DateRange>(() => {
const saved = readPrefs();
if (saved && saved.preset === 'custom') return { start: saved.start, end: saved.end };
return rangeForReportPreset(saved?.preset || 'mes');
});
const [generation, setGeneration] = useState(0);
const [overrides, setOverrides] = useState<Record<string, DateRange>>({});
const setRange = useCallback((nuevo: ReportPreset, start?: string, end?: string) => {
const r = nuevo === 'custom' && start && end ? { start, end } : rangeForReportPreset(nuevo);
setPreset(nuevo);
setRangeState(r);
setGeneration((g) => g + 1);
setOverrides({});
writePrefs({ preset: nuevo, start: r.start, end: r.end });
}, []);
const setOverride = useCallback((key: string, range: DateRange | null) => {
setOverrides((prev) => {
const next = { ...prev };
if (range) next[key] = range;
else delete next[key];
return next;
});
}, []);
const label = useMemo(() => {
const p = REPORT_PRESETS.find((x) => x.value === preset)?.label || 'Rango';
return `${p} · ${range.start} a ${range.end}`;
}, [preset, range]);
const value = useMemo(
() => ({ preset, start: range.start, end: range.end, label, generation, setRange, overrides, setOverride }),
[preset, range, label, generation, setRange, overrides, setOverride],
);
return <GlobalRangeContext.Provider value={value}>{children}</GlobalRangeContext.Provider>;
};
/** Rango global tal cual (lo usa la cabecera de Reportes). */
export const useGlobalReportRange = (): GlobalRange => {
const ctx = useContext(GlobalRangeContext);
if (!ctx) throw new Error('useGlobalReportRange requiere ReportRangeProvider');
return ctx;
};
export interface ReportRange extends DateRange {
setStart: (v: string) => void;
setEnd: (v: string) => void;
/** El reporte está usando un rango propio distinto al global. */
overridden: boolean;
resetOverride: () => void;
globalStart: string;
globalEnd: string;
}
const inicioDeMesISO = () => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
};
/**
* Rango efectivo de un reporte: el global, salvo que ese reporte (identificado
* por `key`) haya fijado uno propio con sus campos Inicio/Fin. El rango propio
* se descarta en cuanto cambia el rango global.
*/
export const useReportRange = (key: string): ReportRange => {
const ctx = useContext(GlobalRangeContext);
const override = ctx?.overrides?.[key] ?? null;
const gStart = ctx?.start ?? inicioDeMesISO();
const gEnd = ctx?.end ?? hoyISO();
const setStart = useCallback((v: string) => {
ctx?.setOverride(key, { start: v, end: override?.end ?? gEnd });
}, [ctx, key, gEnd, override]);
const setEnd = useCallback((v: string) => {
ctx?.setOverride(key, { start: override?.start ?? gStart, end: v });
}, [ctx, key, gStart, override]);
return {
start: override?.start ?? gStart,
end: override?.end ?? gEnd,
setStart,
setEnd,
overridden: override !== null,
resetOverride: () => ctx?.setOverride(key, null),
globalStart: gStart,
globalEnd: gEnd,
};
};
/** Aviso (en la cabecera) de que un reporte usa su propio rango. */
export const RangeOverrideHint: FC<{ reportKey: string }> = ({ reportKey }) => {
const ctx = useContext(GlobalRangeContext);
if (!ctx?.overrides?.[reportKey]) return null;
return (
<button
type="button"
onClick={() => ctx.setOverride(reportKey, null)}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[11px] font-medium border border-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100 transition"
title={`Volver al rango global (${ctx.start} a ${ctx.end})`}
>
<RotateCcw size={11} />
Este reporte usa un rango propio · usar el global
</button>
);
};
/** Etiqueta corta del rango global (para la cabecera de Reportes). */
export const RangeIcon = CalendarRange;
// ---------------------------------------------------------------------------
// Favoritos de reportes (por usuario, en localStorage)
// ---------------------------------------------------------------------------
const FAV_KEY = (uid: number | string) => `skeen_reportes_favs_${uid}`;
export const useReportFavorites = () => {
const { user } = useAuth();
const uid = user?.id ?? 'anon';
const [favs, setFavs] = useState<string[]>([]);
useEffect(() => {
try {
const raw = localStorage.getItem(FAV_KEY(uid));
const parsed = raw ? JSON.parse(raw) : [];
setFavs(Array.isArray(parsed) ? parsed.filter((x) => typeof x === 'string') : []);
} catch {
setFavs([]);
}
}, [uid]);
const persist = (next: string[]) => {
setFavs(next);
try {
localStorage.setItem(FAV_KEY(uid), JSON.stringify(next));
} catch {
// localStorage no disponible: queda solo en memoria
}
};
const toggle = (key: string) => {
persist(favs.includes(key) ? favs.filter((k) => k !== key) : [...favs, key]);
};
return { favs, toggle, isFav: (key: string) => favs.includes(key) };
};

View File

@@ -1,12 +1,14 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft, Percent, Check } from 'lucide-react'; import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft, Percent, Check, Calculator } from 'lucide-react';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import UsuariosPanel from '../components/UsuariosPanel'; import UsuariosPanel from '../components/UsuariosPanel';
import RolesPanel from '../components/RolesPanel'; import RolesPanel from '../components/RolesPanel';
import RecetasPanel from '../components/RecetasPanel'; import RecetasPanel from '../components/RecetasPanel';
import CatalogoPanel from '../components/CatalogoPanel'; import CatalogoPanel from '../components/CatalogoPanel';
import AuditoriaPanel from '../components/AuditoriaPanel';
import ImpactoModal from '../components/ImpactoModal';
import { Card, Button, Input, PageHeader, toast } from '../components/ui'; import { Card, Button, Input, PageHeader, toast } from '../components/ui';
import { odooApi, type Doctor } from '../services/odoo'; import { odooApi, type Doctor } from '../services/odoo';
@@ -16,6 +18,7 @@ const TABS = [
{ key: 'roles', label: 'Roles' }, { key: 'roles', label: 'Roles' },
{ key: 'recetas', label: 'Recetas' }, { key: 'recetas', label: 'Recetas' },
{ key: 'catalogos', label: 'Catálogos' }, { key: 'catalogos', label: 'Catálogos' },
{ key: 'auditoria', label: 'Auditoría' },
] as const; ] as const;
const SUBCATALOGOS = [ const SUBCATALOGOS = [
@@ -117,6 +120,7 @@ const Configuracion: FC = () => {
const [commissionInputs, setCommissionInputs] = useState<Record<number, string>>({}); const [commissionInputs, setCommissionInputs] = useState<Record<number, string>>({});
const [commissionSavingId, setCommissionSavingId] = useState<number | null>(null); const [commissionSavingId, setCommissionSavingId] = useState<number | null>(null);
const [commissionSavedId, setCommissionSavedId] = useState<number | null>(null); const [commissionSavedId, setCommissionSavedId] = useState<number | null>(null);
const [impactoOpen, setImpactoOpen] = useState(false);
useEffect(() => { useEffect(() => {
odooApi.getCommissionDefault() odooApi.getCommissionDefault()
@@ -212,6 +216,8 @@ const Configuracion: FC = () => {
<RecetasPanel /> <RecetasPanel />
</Card.Body> </Card.Body>
</Card> </Card>
) : tab === 'auditoria' ? (
<AuditoriaPanel />
) : tab === 'catalogos' ? ( ) : tab === 'catalogos' ? (
<Card> <Card>
<Card.Body> <Card.Body>
@@ -387,6 +393,18 @@ const Configuracion: FC = () => {
<p className="text-sm text-theme-muted mb-4"> <p className="text-sm text-theme-muted mb-4">
Porcentaje de comisión sobre las ventas atribuidas a cada médico (visible en su página "Mis ventas"). Porcentaje de comisión sobre las ventas atribuidas a cada médico (visible en su página "Mis ventas").
</p> </p>
<div className="mb-5 p-4 bg-theme-bg rounded-xl flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<p className="text-sm font-medium text-theme-heading">¿Cuánto costaría esta configuración?</p>
<p className="text-xs text-theme-muted">
Simula la comisión de cada médico con las ventas atribuidas reales de un mes, sin guardar nada.
</p>
</div>
<Button variant="outline" onClick={() => setImpactoOpen(true)}>
<Calculator size={16} className="mr-2" />
Ver efecto
</Button>
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-end gap-3 max-w-md mb-6"> <div className="flex flex-col sm:flex-row items-start sm:items-end gap-3 max-w-md mb-6">
<div className="flex-1 w-full"> <div className="flex-1 w-full">
<Input <Input
@@ -467,6 +485,12 @@ const Configuracion: FC = () => {
</Card> </Card>
</div> </div>
)} )}
<ImpactoModal
isOpen={impactoOpen}
onClose={() => setImpactoOpen(false)}
comisionGlobal={parseFloat(commissionDefault) || 0}
/>
</Layout> </Layout>
); );
}; };

View File

@@ -1,7 +1,7 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Download, BarChart3 } from 'lucide-react'; import { Download, BarChart3, Star, Wallet, Users, Activity, Stethoscope, Share2 } from 'lucide-react';
import { import {
BarChart, BarChart,
Bar, Bar,
@@ -36,29 +36,89 @@ import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '..
import { odooApi } from '../services/odoo'; import { odooApi } from '../services/odoo';
import type { CommissionRow } from '../services/odoo'; import type { CommissionRow } from '../services/odoo';
import { exportToExcel } from '../lib/exporter'; import { exportToExcel } from '../lib/exporter';
import {
REPORT_PRESETS,
RangeOverrideHint,
ReportRangeProvider,
useGlobalReportRange,
useReportFavorites,
} from '../lib/reportRange';
const COLORS = ['#57534e', '#a8a29e', '#d6d3d1', '#78716c', '#f59e0b', '#10b981']; const COLORS = ['#57534e', '#a8a29e', '#d6d3d1', '#78716c', '#f59e0b', '#10b981'];
const TABS = [ interface ReportTab {
{ key: 'general', label: 'General' }, key: string;
{ key: 'diario', label: 'Movimientos diarios' }, label: string;
{ key: 'cortes', label: 'Cortes de caja' }, desc: string;
{ key: 'ingresos', label: 'Ingresos' }, }
{ key: 'inventario', label: 'Inventario' },
{ key: 'adeudos', label: 'Adeudos' }, interface ReportFamily {
{ key: 'comisiones', label: 'Comisiones' }, key: string;
{ key: 'pagos-servicios', label: 'Pagos · Servicios' }, label: string;
{ key: 'pagos-clientes', label: 'Pagos · Clientes' }, hint: string;
{ key: 'devoluciones', label: 'Devoluciones' }, icon: typeof Wallet;
{ key: 'top-clientes', label: 'Top clientes' }, tabs: ReportTab[];
{ key: 'exportar', label: 'Exportar' }, }
{ key: 'horas-agenda', label: 'Horas agenda' },
{ key: 'paquetes', label: 'Paquetes' }, // Familias de reportes: misma URL ?tab=xxx de siempre, navegación agrupada.
{ key: 'vendedores', label: 'Vendedores' }, const FAMILIAS: ReportFamily[] = [
{ key: 'concentrado', label: 'Concentrado' }, {
{ key: 'recomendaciones', label: 'Recomendaciones' }, key: 'dinero',
{ key: 'kpis', label: 'KPIs' }, label: 'Dinero',
] as const; hint: 'Ingresos, cobros y cartera',
icon: Wallet,
tabs: [
{ key: 'general', label: 'General', desc: 'Resumen del periodo, citas y comisiones' },
{ key: 'diario', label: 'Movimientos diarios', desc: 'Citas, ventas, cobros e inventario del día' },
{ key: 'ingresos', label: 'Ingresos', desc: 'Ventas y formas de pago' },
{ key: 'cortes', label: 'Cortes de caja', desc: 'Arqueos y cierres por día' },
{ key: 'adeudos', label: 'Adeudos', desc: 'Cartera por cobrar y planes de pago' },
{ key: 'devoluciones', label: 'Devoluciones', desc: 'Ventas devueltas y montos' },
],
},
{
key: 'clientes',
label: 'Clientes',
hint: 'Quién compra y cuánto',
icon: Users,
tabs: [
{ key: 'top-clientes', label: 'Top clientes', desc: 'Pacientes con mayor consumo' },
{ key: 'pagos-clientes', label: 'Pagos · Clientes', desc: 'Cobros agrupados por paciente' },
{ key: 'recomendaciones', label: 'Recomendaciones', desc: 'Pacientes que llegan recomendados' },
{ key: 'paquetes', label: 'Paquetes', desc: 'Ventas y avance de paquetes' },
],
},
{
key: 'operacion',
label: 'Operación',
hint: 'Agenda, inventario y servicios',
icon: Activity,
tabs: [
{ key: 'horas-agenda', label: 'Horas agenda', desc: 'Citas atendidas por hora y médico' },
{ key: 'inventario', label: 'Inventario', desc: 'Existencias, mínimos y caducidades' },
{ key: 'pagos-servicios', label: 'Pagos · Servicios', desc: 'Cobros agrupados por servicio' },
{ key: 'concentrado', label: 'Concentrado', desc: 'Todo el periodo en una tabla' },
],
},
{
key: 'equipo',
label: 'Equipo',
hint: 'Rendimiento del personal',
icon: Stethoscope,
tabs: [
{ key: 'comisiones', label: 'Comisiones', desc: 'Comisión por médico en el periodo' },
{ key: 'vendedores', label: 'Vendedores', desc: 'Ventas atribuidas por vendedor' },
{ key: 'kpis', label: 'KPIs', desc: 'No-show, ocupación y conversión' },
],
},
];
const TODOS: ReportTab[] = FAMILIAS.flatMap((f) => f.tabs);
const ETIQUETA_TAB: Record<string, string> = Object.fromEntries(TODOS.map((t) => [t.key, t.label]));
const familiaDe = (tab: string): string =>
FAMILIAS.find((f) => f.tabs.some((t) => t.key === tab))?.key || FAMILIAS[0].key;
interface SalesReport { interface SalesReport {
total_sales: number; total_sales: number;
@@ -77,16 +137,15 @@ interface CashReport {
by_method: Record<string, number>; by_method: Record<string, number>;
} }
const Reportes: FC = () => { const ReportesContent: FC = () => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') || 'general'; const tab = searchParams.get('tab') || 'general';
const setTab = (t: string) => setSearchParams({ tab: t }); const setTab = (t: string) => setSearchParams({ tab: t });
const [start, setStart] = useState(() => { const familiaKey = familiaDe(tab);
const d = new Date(); const familia = FAMILIAS.find((f) => f.key === familiaKey) || FAMILIAS[0];
d.setDate(1); const { favs, toggle, isFav } = useReportFavorites();
return d.toISOString().split('T')[0]; // Rango global del módulo (persistente, compartido por todas las pestañas)
}); const { start, end, preset, setRange } = useGlobalReportRange();
const [end, setEnd] = useState(() => new Date().toISOString().split('T')[0]);
const [today, setToday] = useState(() => new Date().toISOString().split('T')[0]); const [today, setToday] = useState(() => new Date().toISOString().split('T')[0]);
const [salesReport, setSalesReport] = useState<SalesReport | null>(null); const [salesReport, setSalesReport] = useState<SalesReport | null>(null);
const [appointmentsReport, setAppointmentsReport] = useState<AppointmentsReport | null>(null); const [appointmentsReport, setAppointmentsReport] = useState<AppointmentsReport | null>(null);
@@ -96,6 +155,9 @@ const Reportes: FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const tabsFamilia = familia.tabs;
const tabInfo = useMemo(() => TODOS.find((t) => t.key === tab), [tab]);
useEffect(() => { useEffect(() => {
if (tab !== 'general') return; if (tab !== 'general') return;
const load = async () => { const load = async () => {
@@ -181,30 +243,159 @@ const Reportes: FC = () => {
return ( return (
<Layout title="Reportes" subtitle="Análisis del mes y día"> <Layout title="Reportes" subtitle="Análisis del mes y día">
<PageHeader title="Reportes" subtitle="Análisis del mes y día"> <PageHeader title="Reportes" subtitle={tabInfo?.desc || 'Análisis de la operación'}>
{tab === 'general' && ( {tab === 'general' && (
<Button variant="outline" onClick={exportExcel} disabled={loading || !salesReport} loading={exporting}> <Button variant="outline" onClick={exportExcel} disabled={loading || !salesReport} loading={exporting}>
<Download size={16} className="mr-2" /> <Download size={16} className="mr-2" />
Exportar Exportar
</Button> </Button>
)} )}
<Button
variant={tab === 'exportar' ? 'primary' : 'outline'}
onClick={() => setTab('exportar')}
title="Exportar listas de pacientes (correos, VIP, etiquetas)"
>
<Share2 size={16} className="mr-2" />
Exportar datos
</Button>
</PageHeader> </PageHeader>
{/* Tabs de reportes */} {/* Rango de fecha global (aplica a todas las pestañas) */}
<div className="flex flex-wrap gap-1.5 mb-6"> <Card className="mb-4">
{TABS.map((t) => ( <Card.Body className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-medium text-theme-muted uppercase mr-1">Periodo</span>
{REPORT_PRESETS.map((p) => (
<button <button
key={t.key} key={p.value}
type="button" type="button"
onClick={() => setTab(t.key)} onClick={() => setRange(p.value)}
className={`px-4 py-2 text-sm font-medium rounded-full transition ${ className={`px-3 py-1.5 rounded-full text-xs font-medium border transition ${
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg' preset === p.value
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`} }`}
> >
{t.label} {p.label}
</button> </button>
))} ))}
</div> </div>
<div className="flex flex-wrap items-end gap-3">
<Input
label="Inicio"
type="date"
value={start}
onChange={(e) => setRange('custom', e.target.value, end)}
className="sm:max-w-[170px]"
/>
<Input
label="Fin"
type="date"
value={end}
onChange={(e) => setRange('custom', start, e.target.value)}
className="sm:max-w-[170px]"
/>
<RangeOverrideHint reportKey={tab} />
<p className="text-xs text-theme-muted sm:mb-2">
Aplica a todas las pestañas. Cada reporte puede ajustar su propio rango con sus campos Inicio/Fin.
</p>
</div>
</Card.Body>
</Card>
{/* Favoritos por usuario */}
{favs.length > 0 && (
<div className="mb-4">
<p className="text-xs font-medium text-theme-muted uppercase mb-2 inline-flex items-center gap-1.5">
<Star size={12} className="text-amber-500" fill="currentColor" />
Favoritos
</p>
<div className="flex flex-wrap gap-1.5">
{favs.map((k) => (
<span
key={k}
className={`inline-flex items-center rounded-full border overflow-hidden text-xs font-medium ${
tab === k ? 'border-theme-accent bg-theme-accent-bg' : 'border-theme-border bg-theme-bg'
}`}
>
<button
type="button"
onClick={() => setTab(k)}
className={`px-3 py-1.5 ${tab === k ? 'text-theme-heading' : 'text-theme-muted hover:text-theme-heading'}`}
>
{ETIQUETA_TAB[k] || k}
</button>
<button
type="button"
onClick={() => toggle(k)}
title="Quitar de favoritos"
className="px-2 py-1.5 text-amber-500 hover:text-amber-600"
>
<Star size={12} fill="currentColor" />
</button>
</span>
))}
</div>
</div>
)}
{/* Familias */}
<div className="flex flex-wrap gap-1.5 mb-3">
{FAMILIAS.map((f) => {
const Icono = f.icon;
const activa = f.key === familiaKey;
return (
<button
key={f.key}
type="button"
onClick={() => setTab(f.tabs[0].key)}
title={f.hint}
className={`inline-flex items-center gap-1.5 px-4 py-2 text-sm font-semibold rounded-xl transition ${
activa
? 'bg-theme-accent text-theme-inverse shadow-card'
: 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
<Icono size={15} />
{f.label}
</button>
);
})}
</div>
{/* Pestañas de la familia activa */}
<div className="mb-6 pb-3 border-b border-theme-border">
<div className="flex flex-wrap gap-1.5">
{tabsFamilia.map((t) => {
const activo = tab === t.key;
return (
<span
key={t.key}
className={`inline-flex items-center rounded-full border overflow-hidden text-sm font-medium transition ${
activo
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
<button type="button" onClick={() => setTab(t.key)} title={t.desc} className="px-4 py-2">
{t.label}
</button>
<button
type="button"
onClick={() => toggle(t.key)}
title={isFav(t.key) ? 'Quitar de favoritos' : 'Fijar en favoritos'}
aria-label={isFav(t.key) ? 'Quitar de favoritos' : 'Fijar en favoritos'}
className={`px-2.5 py-2 border-l ${
activo ? 'border-theme-inverse/30' : 'border-theme-border'
} ${isFav(t.key) ? 'text-amber-500' : activo ? 'text-theme-inverse/60 hover:text-theme-inverse' : 'text-theme-muted/60 hover:text-theme-heading'}`}
>
<Star size={13} fill={isFav(t.key) ? 'currentColor' : 'none'} />
</button>
</span>
);
})}
</div>
</div>
{tab === 'diario' ? ( {tab === 'diario' ? (
<DailyReport /> <DailyReport />
@@ -245,20 +436,6 @@ const Reportes: FC = () => {
<Card className="mb-6"> <Card className="mb-6">
<Card.Body> <Card.Body>
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4"> <div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4">
<Input
label="Inicio"
type="date"
value={start}
onChange={(e) => setStart(e.target.value)}
className="sm:max-w-[180px]"
/>
<Input
label="Fin"
type="date"
value={end}
onChange={(e) => setEnd(e.target.value)}
className="sm:max-w-[180px]"
/>
<Input <Input
label="Día efectivo" label="Día efectivo"
type="date" type="date"
@@ -266,6 +443,10 @@ const Reportes: FC = () => {
onChange={(e) => setToday(e.target.value)} onChange={(e) => setToday(e.target.value)}
className="sm:max-w-[180px]" className="sm:max-w-[180px]"
/> />
<p className="text-xs text-theme-muted sm:mb-2">
Ventas, citas y comisiones usan el <span className="font-medium text-theme-heading">periodo global</span> de arriba
({start} a {end}).
</p>
</div> </div>
{error && <p className="text-rose-600 text-sm mt-4">{error}</p>} {error && <p className="text-rose-600 text-sm mt-4">{error}</p>}
@@ -397,4 +578,11 @@ const Reportes: FC = () => {
); );
}; };
/** Envuelve la vista con el proveedor del rango global. */
const Reportes: FC = () => (
<ReportRangeProvider>
<ReportesContent />
</ReportRangeProvider>
);
export default Reportes; export default Reportes;

View File

@@ -260,6 +260,59 @@ export interface Service {
genera_monedero?: boolean; genera_monedero?: boolean;
} }
export interface ImpactSimulationDoctor {
doctor_id: number;
doctor: string;
job_title: string;
base: number;
commission_pct_propio: number;
commission_pct_efectivo: number;
origen_pct: 'propio' | 'global' | 'propuesto';
commission: number;
}
export interface ImpactSimulation {
status: string;
date_from: string | null;
date_to: string | null;
commission_pct_global: number;
commission_pct_propuesto: number | null;
doctores: ImpactSimulationDoctor[];
total_base: number;
total_commission: number;
ventas: number;
rate: number | null;
rate_actual: number | null;
total_usd: number | null;
message?: string;
}
export interface ConfigAuditRow {
id: number;
date: string | null;
user_login: string;
user_name: string;
user_role: string;
module: string;
module_label: string;
entity: string;
entity_label: string;
field: string;
old_value: string;
new_value: string;
notes: string;
}
export interface ConfigAuditResponse {
status: string;
registros: ConfigAuditRow[];
total: number;
page: number;
page_size: number;
total_pages: number;
message?: string;
}
export interface Doctor { export interface Doctor {
id: number; id: number;
name: string; name: string;
@@ -1433,6 +1486,23 @@ export const odooApi = {
return data; return data;
}, },
// Simulación de impacto ("Ver efecto") con las ventas atribuidas del periodo
async getImpactSimulation(params: {
date_from?: string; date_to?: string; commission_pct?: number; rate?: number;
}): Promise<ImpactSimulation> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/config/impact_simulation`, { params });
return data;
},
// Auditoría de configuración
async getConfigAudit(params?: {
date_from?: string; date_to?: string; module?: string; user?: string;
entity?: string; page?: number; page_size?: number;
}): Promise<ConfigAuditResponse> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/config/audit`, { params });
return data;
},
async getDoctorHistory(id: number): Promise<DoctorHistory> { async getDoctorHistory(id: number): Promise<DoctorHistory> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}/history`); const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}/history`);
return data; return data;

View File

@@ -49,6 +49,33 @@ class SkeenFrontendController(http.Controller):
return False return False
return self.AGENDA_START <= t < self.AGENDA_END return self.AGENDA_START <= t < self.AGENDA_END
# ------------------------------------------------------------------
# Auditoría de configuración
# ------------------------------------------------------------------
def _fu_user(self):
"""Usuario del frontend que hizo la petición (o None)."""
return getattr(request, 'frontend_user', None)
def _auditar(self, module, entity, field, old_value=None, new_value=None,
entity_label='', notes=''):
"""Registra un cambio de configuración en skeen.config.audit.
Nunca debe tumbar la operación principal: si la bitácora falla, se
ignora el error y el cambio de configuración sigue adelante.
"""
try:
request.env['skeen.config.audit'].sudo().log_change(
module=module, entity=entity, field=field,
old_value=old_value, new_value=new_value,
entity_label=entity_label, notes=notes, user=self._fu_user())
except Exception:
import logging
logging.getLogger(__name__).exception('No se pudo registrar la auditoría')
def _fmt_horas(self, h):
h = float(h or 0)
return '%d:%02d' % (int(h), int(round((h - int(h)) * 60)))
def _linked_employee(self, user): def _linked_employee(self, user):
"""hr.employee vinculado a un usuario frontend (rol medico). """hr.employee vinculado a un usuario frontend (rol medico).
@@ -164,6 +191,8 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'El usuario ya existe'}, 409) return json_response({'status': 'error', 'message': 'El usuario ya existe'}, 409)
user = User.create({'login': login, 'name': name, 'role': role, 'password_hash': 'pending'}) user = User.create({'login': login, 'name': name, 'role': role, 'password_hash': 'pending'})
user.set_password(password) user.set_password(password)
self._auditar('usuarios', f'usuario:{user.id}', 'alta', '', f'{login} ({name})',
entity_label=name, notes=f'Rol {role}')
return json_response({'status': 'success', 'user': user.to_public_dict()}, 201) return json_response({'status': 'success', 'user': user.to_public_dict()}, 201)
except Exception as e: except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500) return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -193,11 +222,20 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'allowed_menus debe ser una lista'}, 400) return json_response({'status': 'error', 'message': 'allowed_menus debe ser una lista'}, 400)
vals['allowed_menus'] = json.dumps(menus) if menus else False vals['allowed_menus'] = json.dumps(menus) if menus else False
if vals: if vals:
previos = {k: getattr(user, k) for k in vals}
user.write(vals) user.write(vals)
for campo, nuevo in vals.items():
anterior = previos.get(campo)
if str(anterior or '') == str(nuevo or ''):
continue
self._auditar('usuarios', f'usuario:{user.id}', campo, anterior, nuevo,
entity_label=user.name or user.login)
if data.get('password'): if data.get('password'):
if len(data['password']) < 8: if len(data['password']) < 8:
return json_response({'status': 'error', 'message': 'La contraseña debe tener al menos 8 caracteres'}, 400) return json_response({'status': 'error', 'message': 'La contraseña debe tener al menos 8 caracteres'}, 400)
user.set_password(data['password']) user.set_password(data['password'])
self._auditar('usuarios', f'usuario:{user.id}', 'password', '(oculto)', '(cambiada)',
entity_label=user.name or user.login)
return json_response({'status': 'success', 'user': user.to_public_dict() | {'active': user.active}}) return json_response({'status': 'success', 'user': user.to_public_dict() | {'active': user.active}})
except Exception as e: except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500) return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -211,7 +249,10 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404) return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404)
if request.frontend_user and request.frontend_user.id == user.id: if request.frontend_user and request.frontend_user.id == user.id:
return json_response({'status': 'error', 'message': 'No puedes eliminar tu propio usuario'}, 400) return json_response({'status': 'error', 'message': 'No puedes eliminar tu propio usuario'}, 400)
etiqueta = f'{user.login} ({user.name})'
user.unlink() user.unlink()
self._auditar('usuarios', f'usuario:{user_id}', 'baja', etiqueta, '',
entity_label=etiqueta, notes='Usuario eliminado')
return json_response({'status': 'success', 'message': 'Usuario eliminado'}) return json_response({'status': 'success', 'message': 'Usuario eliminado'})
except Exception as e: except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500) return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -248,7 +289,13 @@ class SkeenFrontendController(http.Controller):
clean[href] = list(dict.fromkeys(roles)) clean[href] = list(dict.fromkeys(roles))
if 'admin' not in clean.get('/configuracion', []): if 'admin' not in clean.get('/configuracion', []):
return json_response({'status': 'error', 'message': "No se puede quitar '/configuracion' al rol admin"}, 400) return json_response({'status': 'error', 'message': "No se puede quitar '/configuracion' al rol admin"}, 400)
request.env['ir.config_parameter'].sudo().set_param(MENU_ROLES_KEY, json.dumps(clean)) ICP = request.env['ir.config_parameter'].sudo()
anterior = ICP.get_param(MENU_ROLES_KEY, '') or ''
ICP.set_param(MENU_ROLES_KEY, json.dumps(clean))
self._auditar('roles', 'menu_roles', 'permisos',
(anterior[:2000] or '(por defecto)'), json.dumps(clean)[:2000],
entity_label='Matriz de menús por rol',
notes=f'{len(clean)} rutas')
return json_response({'status': 'success', 'matrix': clean}) return json_response({'status': 'success', 'matrix': clean})
except Exception as e: except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500) return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -2514,7 +2561,15 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': "staff_type debe ser 'doctor' o 'cosmetologa'"}, 400) return json_response({'status': 'error', 'message': "staff_type debe ser 'doctor' o 'cosmetologa'"}, 400)
vals['staff_type'] = data['staff_type'] vals['staff_type'] = data['staff_type']
if vals: if vals:
previos = {k: getattr(doctor, k) for k in vals}
doctor.write(vals) doctor.write(vals)
for campo, nuevo in vals.items():
anterior = previos.get(campo)
if str(anterior or '') == str(nuevo or ''):
continue
modulo = 'metas' if campo == 'monthly_goal' else 'comisiones'
self._auditar(modulo, f'doctor:{doctor.id}', campo,
anterior, nuevo, entity_label=doctor.name)
return json_response({ return json_response({
'status': 'success', 'status': 'success',
'doctor': { 'doctor': {
@@ -2587,7 +2642,12 @@ class SkeenFrontendController(http.Controller):
}, 400) }, 400)
vistos.add(dia) vistos.add(dia)
vals.append((0, 0, {'day_of_week': dia, 'hour_from': hf, 'hour_to': ht})) vals.append((0, 0, {'day_of_week': dia, 'hour_from': hf, 'hour_to': ht}))
previo = doctor._horario_resumen() or '(sin horario)'
doctor.write({'horario_ids': [(5, 0, 0)] + vals}) doctor.write({'horario_ids': [(5, 0, 0)] + vals})
nuevo = doctor._horario_resumen() or '(sin horario)'
if previo != nuevo:
self._auditar('horarios', f'doctor:{doctor.id}', 'horario',
previo, nuevo, entity_label=doctor.name)
return json_response({ return json_response({
'status': 'success', 'status': 'success',
'horario': [{ 'horario': [{
@@ -2617,7 +2677,11 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'commission_pct debe ser numérico'}, 400) return json_response({'status': 'error', 'message': 'commission_pct debe ser numérico'}, 400)
if not 0 <= pct <= 100: if not 0 <= pct <= 100:
return json_response({'status': 'error', 'message': 'El porcentaje debe estar entre 0 y 100'}, 400) return json_response({'status': 'error', 'message': 'El porcentaje debe estar entre 0 y 100'}, 400)
prev = doctor.commission_pct or 0.0
doctor.write({'commission_pct': pct}) doctor.write({'commission_pct': pct})
if prev != pct:
self._auditar('comisiones', f'doctor:{doctor.id}', 'commission_pct',
prev, pct, entity_label=doctor.name)
return json_response({ return json_response({
'status': 'success', 'status': 'success',
'doctor': {'id': doctor.id, 'name': doctor.name, 'commission_pct': doctor.commission_pct}, 'doctor': {'id': doctor.id, 'name': doctor.name, 'commission_pct': doctor.commission_pct},
@@ -4644,12 +4708,16 @@ class SkeenFrontendController(http.Controller):
today = fields.Date.today() today = fields.Date.today()
TC = request.env['skeen.tipo.cambio'].sudo() TC = request.env['skeen.tipo.cambio'].sudo()
existing = TC.search([('date', '=', today)], limit=1) existing = TC.search([('date', '=', today)], limit=1)
prev_rate = existing.rate if existing else None
vals = {'rate': rate, 'source': data.get('source', 'manual'), 'notes': data.get('notes', '')} vals = {'rate': rate, 'source': data.get('source', 'manual'), 'notes': data.get('notes', '')}
if existing: if existing:
existing.write(vals) existing.write(vals)
rec = existing rec = existing
else: else:
rec = TC.create({'date': today, **vals}) rec = TC.create({'date': today, **vals})
self._auditar('tipo_cambio', f'tipo_cambio:{rec.id}', 'rate',
prev_rate, rate, entity_label=f'TC {today}',
notes='Alta' if prev_rate is None else 'Actualización')
return json_response({ return json_response({
'status': 'success', 'status': 'success',
'exchange_rate': {'id': rec.id, 'date': rec.date.strftime('%Y-%m-%d'), 'rate': rec.rate, 'source': rec.source}, 'exchange_rate': {'id': rec.id, 'date': rec.date.strftime('%Y-%m-%d'), 'rate': rec.rate, 'source': rec.source},
@@ -4721,11 +4789,163 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'commission_pct debe ser numérico'}, 400) return json_response({'status': 'error', 'message': 'commission_pct debe ser numérico'}, 400)
if not 0 <= pct <= 100: if not 0 <= pct <= 100:
return json_response({'status': 'error', 'message': 'El porcentaje debe estar entre 0 y 100'}, 400) return json_response({'status': 'error', 'message': 'El porcentaje debe estar entre 0 y 100'}, 400)
request.env['ir.config_parameter'].sudo().set_param('skeen.commission_pct', pct) ICP = request.env['ir.config_parameter'].sudo()
prev = float(ICP.get_param('skeen.commission_pct', 0) or 0)
ICP.set_param('skeen.commission_pct', pct)
self._auditar('comisiones', 'commission_default', 'commission_pct',
prev, pct, entity_label='Comisión global')
return json_response({'status': 'success', 'commission_pct': pct}) return json_response({'status': 'success', 'commission_pct': pct})
except Exception as e: except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500) return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Simulación de impacto y auditoría de configuración
# ============================================================
@http.route('/skeen/frontend/v1/config/impact_simulation', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('admin')
def config_impact_simulation(self, **kw):
"""Qué habría pasado con los valores configurados (o unos propuestos).
Calcula, con las ventas atribuidas REALES del periodo, cuánto gana cada
médico por comisión. Parámetros opcionales `commission_pct` y `rate`
permiten probar valores candidatos sin guardarlos.
"""
try:
start = kw.get('date_from') or kw.get('start')
end = kw.get('date_to') or kw.get('end')
ICP = request.env['ir.config_parameter'].sudo()
pct_global = float(ICP.get_param('skeen.commission_pct', 0) or 0)
propuesto = kw.get('commission_pct')
pct_propuesto = None
if propuesto not in (None, ''):
try:
pct_propuesto = float(propuesto)
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'commission_pct debe ser numérico'}, 400)
if not 0 <= pct_propuesto <= 100:
return json_response({'status': 'error', 'message': 'El porcentaje debe estar entre 0 y 100'}, 400)
vdomain = [('state', '!=', 'cancelled')]
if start:
vdomain.append(('date', '>=', start))
if end:
vdomain.append(('date', '<=', end))
ventas = request.env['skeen.venta'].sudo().search(vdomain)
lineas = request.env['skeen.venta.line'].sudo().search([
('venta_id', 'in', ventas.ids), ('prescribed_by_id', '!=', False),
])
base_por_doc = {}
for l in lineas:
did = l.prescribed_by_id.id
base_por_doc[did] = base_por_doc.get(did, 0.0) + (l.subtotal or 0.0)
Emp = request.env['hr.employee'].sudo()
filas = []
total_base = 0.0
total_comision = 0.0
for did, base in base_por_doc.items():
emp = Emp.browse(did)
propio = emp.commission_pct or 0.0
if pct_propuesto is not None:
pct, origen = pct_propuesto, 'propuesto'
elif propio > 0:
pct, origen = propio, 'propio'
else:
pct, origen = pct_global, 'global'
comision = base * pct / 100.0
total_base += base
total_comision += comision
filas.append({
'doctor_id': did,
'doctor': emp.name,
'job_title': emp.job_title or '',
'base': round(base, 2),
'commission_pct_propio': propio,
'commission_pct_efectivo': pct,
'origen_pct': origen,
'commission': round(comision, 2),
})
filas.sort(key=lambda f: -f['commission'])
# Equivalente USD del periodo con el TC vigente (o uno propuesto)
TC = request.env['skeen.tipo.cambio'].sudo().search([], order='date desc, id desc', limit=1)
rate_actual = TC.rate if TC else None
rate = rate_actual
if kw.get('rate') not in (None, ''):
try:
rate = float(kw.get('rate'))
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'rate debe ser numérico'}, 400)
if rate <= 0:
return json_response({'status': 'error', 'message': 'rate debe ser mayor a 0'}, 400)
return json_response({
'status': 'success',
'date_from': start,
'date_to': end,
'commission_pct_global': pct_global,
'commission_pct_propuesto': pct_propuesto,
'doctores': filas,
'total_base': round(total_base, 2),
'total_commission': round(total_comision, 2),
'ventas': len(ventas),
'rate': rate,
'rate_actual': rate_actual,
'total_usd': round(total_base / rate, 2) if rate else None,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/config/audit', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('admin')
def config_audit_list(self, **kw):
"""Bitácora de cambios de configuración (filtrable y paginada)."""
try:
domain = []
if kw.get('date_from'):
domain.append(('date', '>=', f"{kw['date_from']} 00:00:00"))
if kw.get('date_to'):
domain.append(('date', '<=', f"{kw['date_to']} 23:59:59"))
if kw.get('module'):
domain.append(('module', '=', kw['module']))
if kw.get('user'):
domain += ['|', ('user_login', 'ilike', kw['user']), ('user_name', 'ilike', kw['user'])]
if kw.get('entity'):
e = kw['entity']
domain += ['|', ('entity', 'ilike', e), ('entity_label', 'ilike', e)]
page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
Audit = request.env['skeen.config.audit'].sudo()
total = Audit.search_count(domain)
recs = Audit.search(domain, order='date desc, id desc',
limit=page_size, offset=(page - 1) * page_size)
return json_response({
'status': 'success',
'registros': [{
'id': r.id,
'date': r.date.strftime('%Y-%m-%d %H:%M') if r.date else None,
'user_login': r.user_login or '',
'user_name': r.user_name or '',
'user_role': r.user_role or '',
'module': r.module,
'module_label': dict(
request.env['skeen.config.audit']._fields['module'].selection).get(r.module, r.module),
'entity': r.entity or '',
'entity_label': r.entity_label or '',
'field': r.field or '',
'old_value': r.old_value or '',
'new_value': r.new_value or '',
'notes': r.notes or '',
} for r in recs],
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================ # ============================================================
# Reporte de cumpleañeros # Reporte de cumpleañeros
# ============================================================ # ============================================================

View File

@@ -1,3 +1,4 @@
from . import main from . import main
from . import wacrm_sync from . import wacrm_sync
from . import frontend_user from . import frontend_user
from . import config_audit

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class SkeenConfigAudit(models.Model):
"""Bitácora de cambios en la configuración sensible de la clínica.
Se escribe desde los endpoints de configuración del frontend (comisiones,
tipo de cambio, metas, horarios, roles/permisos, usuarios, catálogos) y
registra quién, qué entidad, qué campo y los valores anterior/nuevo.
"""
_name = 'skeen.config.audit'
_description = 'Auditoría de configuración SKEEN'
_order = 'date desc, id desc'
date = fields.Datetime(string='Fecha', default=fields.Datetime.now, index=True)
user_login = fields.Char(string='Usuario', index=True)
user_name = fields.Char(string='Nombre del usuario')
user_role = fields.Char(string='Rol')
module = fields.Selection([
('comisiones', 'Comisiones'),
('tipo_cambio', 'Tipo de cambio'),
('metas', 'Metas'),
('horarios', 'Horarios'),
('roles', 'Roles y permisos'),
('usuarios', 'Usuarios'),
('catalogos', 'Catálogos'),
('clinica', 'Clínica'),
], string='Módulo', required=True, index=True)
entity = fields.Char(string='Entidad', index=True,
help='Identificador de lo modificado (p. ej. doctor:24, usuario:5)')
entity_label = fields.Char(string='Descripción')
field = fields.Char(string='Campo')
old_value = fields.Text(string='Valor anterior')
new_value = fields.Text(string='Valor nuevo')
notes = fields.Char(string='Nota')
@api.model
def log_change(self, module, entity, field, old_value=None, new_value=None,
entity_label='', notes='', user=None):
"""Registra un cambio. `user` es el skeen.frontend.user que lo hizo."""
vals = {
'module': module,
'entity': entity or '',
'entity_label': entity_label or '',
'field': field or '',
'old_value': '' if old_value is None else str(old_value),
'new_value': '' if new_value is None else str(new_value),
'notes': notes or '',
}
if user is not None:
vals.update({
'user_login': getattr(user, 'login', '') or '',
'user_name': getattr(user, 'name', '') or '',
'user_role': getattr(user, 'role', '') or '',
})
return self.sudo().create(vals)

View File

@@ -6,3 +6,4 @@ access_skeen_wacrm_pipeline,skeen.wacrm.pipeline,model_skeen_wacrm_pipeline,base
access_skeen_wacrm_stage,skeen.wacrm.stage,model_skeen_wacrm_stage,base.group_user,1,1,1,1 access_skeen_wacrm_stage,skeen.wacrm.stage,model_skeen_wacrm_stage,base.group_user,1,1,1,1
access_skeen_wacrm_deal,skeen.wacrm.deal,model_skeen_wacrm_deal,base.group_user,1,1,1,1 access_skeen_wacrm_deal,skeen.wacrm.deal,model_skeen_wacrm_deal,base.group_user,1,1,1,1
access_skeen_frontend_user,skeen.frontend.user,model_skeen_frontend_user,base.group_user,1,1,1,1 access_skeen_frontend_user,skeen.frontend.user,model_skeen_frontend_user,base.group_user,1,1,1,1
access_skeen_config_audit,skeen.config.audit,model_skeen_config_audit,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
6 access_skeen_wacrm_stage skeen.wacrm.stage model_skeen_wacrm_stage base.group_user 1 1 1 1
7 access_skeen_wacrm_deal skeen.wacrm.deal model_skeen_wacrm_deal base.group_user 1 1 1 1
8 access_skeen_frontend_user skeen.frontend.user model_skeen_frontend_user base.group_user 1 1 1 1
9 access_skeen_config_audit skeen.config.audit model_skeen_config_audit base.group_user 1 1 1 1