diff --git a/frontend/src/components/AuditoriaPanel.tsx b/frontend/src/components/AuditoriaPanel.tsx new file mode 100644 index 0000000..c8e6caa --- /dev/null +++ b/frontend/src/components/AuditoriaPanel.tsx @@ -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([]); + 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 ( +
+
+ setUsuario(e.target.value)} + className="pl-9" + /> +
+ setEntidad(e.target.value)} + className="sm:max-w-[190px]" + /> + setDesde(e.target.value)} + className="sm:max-w-[160px]" + /> + setHasta(e.target.value)} + className="sm:max-w-[160px]" + /> + {hayFiltros && ( + + )} +
+ + + +
+

+ Cambios de configuración +

+

+ {total} {total === 1 ? 'registro' : 'registros'} · página {page} de {totalPages || 1} +

+
+ + {loading ? ( + + ) : rows.length === 0 ? ( + } + /> + ) : ( +
+ + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + + ))} + +
FechaUsuarioMóduloEntidadCampoAntesDespués
{fmtFecha(r.date)} +

{r.user_name || r.user_login || '—'}

+

{r.user_login}{r.user_role ? ` · ${r.user_role}` : ''}

+
+ + {r.module_label} + + +

{r.entity_label || r.entity || '—'}

+ {r.entity_label && r.entity &&

{r.entity}

} +
{r.field || '—'}{recorta(r.old_value)}{recorta(r.new_value)}
+
+ )} + + {totalPages > 1 && ( +
+ + +
+ )} +
+
+ + ); +}; + +export default AuditoriaPanel; diff --git a/frontend/src/components/ImpactoModal.tsx b/frontend/src/components/ImpactoModal.tsx new file mode 100644 index 0000000..e57d08d --- /dev/null +++ b/frontend/src/components/ImpactoModal.tsx @@ -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 = { + 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 = ({ 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(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 ( + + + + + } + > +
+

+ Calculado con las ventas atribuidas reales del periodo + (líneas recetadas por cada médico). No guarda nada: solo simula. +

+ +
+ setDesde(e.target.value)} /> + setHasta(e.target.value)} /> + setPct(e.target.value)} + /> + setRate(e.target.value)} + /> +
+ +
+ + + % global configurado: {data?.commission_pct_global ?? comisionGlobal}% + + {data?.commission_pct_propuesto != null && ( + + simulando con {data.commission_pct_propuesto}% para todos + + )} +
+ + {loading ? ( + + ) : !data ? ( +

Sin datos.

+ ) : ( + <> +
+
+

Ventas del periodo

+

{data.ventas}

+
+
+

Base comisionable

+

{money(data.total_base)}

+
+
+

Comisión total

+

{money(data.total_commission)}

+
+
+

Base en USD (TC {data.rate ?? '—'})

+

{usd(data.total_usd)}

+
+
+ + {data.doctores.length === 0 ? ( +

+ No hay ventas con artículo recetado en ese periodo, así que la comisión sería cero. +

+ ) : ( +
+ + + + + + + + + + + + {data.doctores.map((d) => ( + + + + + + + + ))} + +
MédicoBase% aplicadoOrigenComisión
+

{d.doctor}

+

{d.job_title || '—'}

+
{money(d.base)}{d.commission_pct_efectivo}%{ORIGEN[d.origen_pct] || d.origen_pct}{money(d.commission)}
+
+ )} + +

+ 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. +

+ + )} +
+
+ ); +}; + +export default ImpactoModal; diff --git a/frontend/src/components/reportes/ComisionesReport.tsx b/frontend/src/components/reportes/ComisionesReport.tsx index 7b963ad..963dfd3 100644 --- a/frontend/src/components/reportes/ComisionesReport.tsx +++ b/frontend/src/components/reportes/ComisionesReport.tsx @@ -5,20 +5,15 @@ import { Card, Input, EmptyState, Skeleton, toast } from '../ui'; import { ExportButton } from '../ui/ExportButton'; import { exportToExcel } from '../../lib/exporter'; import { odooApi, type ComisionRow } from '../../services/odoo'; +import { useReportRange } from '../../lib/reportRange'; const formatCurrency = (value: number) => 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 [start, setStart] = useState(inicioDeMes()); - const [end, setEnd] = useState(hoy()); + // Rango global de Reportes; los campos Inicio/Fin fijan uno propio + const { start, end, setStart, setEnd } = useReportRange('comisiones'); const [rows, setRows] = useState([]); const [totales, setTotales] = useState({ vendido: 0, comision: 0 }); const [loading, setLoading] = useState(true); @@ -79,6 +74,7 @@ const ComisionesReport: FC = () => {
setStart(e.target.value)} className="sm:max-w-[160px]" /> setEnd(e.target.value)} className="sm:max-w-[160px]" /> +

diff --git a/frontend/src/components/reportes/ConcentradoReport.tsx b/frontend/src/components/reportes/ConcentradoReport.tsx index bdf9437..9e653da 100644 --- a/frontend/src/components/reportes/ConcentradoReport.tsx +++ b/frontend/src/components/reportes/ConcentradoReport.tsx @@ -6,6 +6,7 @@ import { ExportButton } from '../ui/ExportButton'; import { exportToExcel } from '../../lib/exporter'; import { odooApi, type ConcentradoReport as ConcentradoData } from '../../services/odoo'; import type { BadgeVariant } from '../ui/Badge'; +import { useReportRange } from '../../lib/reportRange'; const formatCurrency = (value: number) => 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 === '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 }) => ( @@ -41,8 +36,8 @@ const Kpi: FC<{ label: string; value: React.ReactNode; rose?: boolean }> = ({ la ); const ConcentradoReport: FC = () => { - const [start, setStart] = useState(inicioDeMes()); - const [end, setEnd] = useState(hoy()); + // Rango global de Reportes; los campos Inicio/Fin fijan uno propio + const { start, end, setStart, setEnd } = useReportRange('concentrado'); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -111,6 +106,7 @@ const ConcentradoReport: FC = () => {

setStart(e.target.value)} className="sm:max-w-[160px]" /> setEnd(e.target.value)} className="sm:max-w-[160px]" /> +
diff --git a/frontend/src/components/reportes/DailyReport.tsx b/frontend/src/components/reportes/DailyReport.tsx index e2c6105..a44f825 100644 --- a/frontend/src/components/reportes/DailyReport.tsx +++ b/frontend/src/components/reportes/DailyReport.tsx @@ -29,11 +29,15 @@ const metodoLabels: Record = { 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 = { compra: 'Compra', venta: 'Venta', baja: 'Baja', ajuste: 'Ajuste', }; -const hoy = () => new Date().toISOString().split('T')[0]; const DailyReport: FC = () => { const [fecha, setFecha] = useState(hoy()); diff --git a/frontend/src/components/reportes/DevolucionesReport.tsx b/frontend/src/components/reportes/DevolucionesReport.tsx index 2a47716..a238018 100644 --- a/frontend/src/components/reportes/DevolucionesReport.tsx +++ b/frontend/src/components/reportes/DevolucionesReport.tsx @@ -5,20 +5,15 @@ import { Card, Input, EmptyState, Skeleton, toast } from '../ui'; import { ExportButton } from '../ui/ExportButton'; import { exportToExcel } from '../../lib/exporter'; import { odooApi, type DevolucionRow } from '../../services/odoo'; +import { useReportRange } from '../../lib/reportRange'; const formatCurrency = (value: number) => 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 [start, setStart] = useState(inicioDeMes()); - const [end, setEnd] = useState(hoy()); + // Rango global de Reportes; los campos Inicio/Fin fijan uno propio + const { start, end, setStart, setEnd } = useReportRange('devoluciones'); const [devoluciones, setDevoluciones] = useState([]); const [totalDevuelto, setTotalDevuelto] = useState(0); const [loading, setLoading] = useState(true); @@ -78,6 +73,7 @@ const DevolucionesReport: FC = () => {
setStart(e.target.value)} className="sm:max-w-[160px]" /> setEnd(e.target.value)} className="sm:max-w-[160px]" /> +

diff --git a/frontend/src/components/reportes/HorasAgendaReport.tsx b/frontend/src/components/reportes/HorasAgendaReport.tsx index 487efa1..f9624e9 100644 --- a/frontend/src/components/reportes/HorasAgendaReport.tsx +++ b/frontend/src/components/reportes/HorasAgendaReport.tsx @@ -15,6 +15,7 @@ import { ExportButton } from '../ui/ExportButton'; import { exportToExcel } from '../../lib/exporter'; import { odooApi, type HoraAgendaRow } from '../../services/odoo'; import type { BadgeVariant } from '../ui/Badge'; +import { useReportRange } from '../../lib/reportRange'; const citaStateLabels: Record = { 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 [start, setStart] = useState(hoy()); - const [end, setEnd] = useState(hoy()); + // Rango global de Reportes; los campos Inicio/Fin fijan uno propio + const { start, end, setStart, setEnd } = useReportRange('horas-agenda'); const [horas, setHoras] = useState([]); const [loading, setLoading] = useState(true); @@ -94,6 +94,7 @@ const HorasAgendaReport: FC = () => {

setStart(e.target.value)} className="sm:max-w-[160px]" /> setEnd(e.target.value)} className="sm:max-w-[160px]" /> +

{total} citas en el periodo

diff --git a/frontend/src/components/reportes/IngresosReport.tsx b/frontend/src/components/reportes/IngresosReport.tsx index fe9765c..dccb17b 100644 --- a/frontend/src/components/reportes/IngresosReport.tsx +++ b/frontend/src/components/reportes/IngresosReport.tsx @@ -14,6 +14,7 @@ import { Card, Input, EmptyState, Skeleton, toast } from '../ui'; import { ExportButton } from '../ui/ExportButton'; import { exportToExcel } from '../../lib/exporter'; import { odooApi } from '../../services/odoo'; +import { useReportRange } from '../../lib/reportRange'; const formatCurrency = (value: number) => new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value); @@ -23,12 +24,6 @@ const metodoLabels: Record = { 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 { total_sales: number; @@ -39,8 +34,8 @@ interface SalesData { } const IngresosReport: FC = () => { - const [start, setStart] = useState(inicioDeMes()); - const [end, setEnd] = useState(hoy()); + // Rango global de Reportes; los campos Inicio/Fin fijan uno propio + const { start, end, setStart, setEnd } = useReportRange('ingresos'); const [sales, setSales] = useState(null); const [byMethod, setByMethod] = useState>({}); const [loading, setLoading] = useState(true); @@ -117,6 +112,7 @@ const IngresosReport: FC = () => {
setStart(e.target.value)} className="sm:max-w-[180px]" /> setEnd(e.target.value)} className="sm:max-w-[180px]" /> +
diff --git a/frontend/src/components/reportes/KpisReport.tsx b/frontend/src/components/reportes/KpisReport.tsx index f00ecfd..7617d42 100644 --- a/frontend/src/components/reportes/KpisReport.tsx +++ b/frontend/src/components/reportes/KpisReport.tsx @@ -14,15 +14,10 @@ import { Card, Input, EmptyState, Skeleton, toast } from '../ui'; import { ExportButton } from '../ui/ExportButton'; import { exportToExcel } from '../../lib/exporter'; import { odooApi, type KpisReport as KpisData } from '../../services/odoo'; +import { useReportRange } from '../../lib/reportRange'; 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 }) => ( @@ -35,8 +30,8 @@ const Kpi: FC<{ label: string; value: React.ReactNode; icon: React.ReactNode; ro ); const KpisReport: FC = () => { - const [start, setStart] = useState(inicioDeMes()); - const [end, setEnd] = useState(hoy()); + // Rango global de Reportes; los campos Inicio/Fin fijan uno propio + const { start, end, setStart, setEnd } = useReportRange('kpis'); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [exporting, setExporting] = useState(false); @@ -102,6 +97,7 @@ const KpisReport: FC = () => {
setStart(e.target.value)} className="sm:max-w-[160px]" /> setEnd(e.target.value)} className="sm:max-w-[160px]" /> +
diff --git a/frontend/src/components/reportes/PagosClientesReport.tsx b/frontend/src/components/reportes/PagosClientesReport.tsx index 8ae7b5c..ccb3b1c 100644 --- a/frontend/src/components/reportes/PagosClientesReport.tsx +++ b/frontend/src/components/reportes/PagosClientesReport.tsx @@ -5,20 +5,15 @@ import { Card, Input, EmptyState, Skeleton, toast } from '../ui'; import { ExportButton } from '../ui/ExportButton'; import { exportToExcel } from '../../lib/exporter'; import { odooApi, type PagoClienteRow } from '../../services/odoo'; +import { useReportRange } from '../../lib/reportRange'; const formatCurrency = (value: number) => 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 [start, setStart] = useState(inicioDeMes()); - const [end, setEnd] = useState(hoy()); + // Rango global de Reportes; los campos Inicio/Fin fijan uno propio + const { start, end, setStart, setEnd } = useReportRange('pagos-clientes'); const [clientes, setClientes] = useState([]); const [totalCobrado, setTotalCobrado] = useState(0); const [loading, setLoading] = useState(true); @@ -75,6 +70,7 @@ const PagosClientesReport: FC = () => {
setStart(e.target.value)} className="sm:max-w-[160px]" /> setEnd(e.target.value)} className="sm:max-w-[160px]" /> +

diff --git a/frontend/src/components/reportes/PagosServiciosReport.tsx b/frontend/src/components/reportes/PagosServiciosReport.tsx index 43a9ef5..b35c69f 100644 --- a/frontend/src/components/reportes/PagosServiciosReport.tsx +++ b/frontend/src/components/reportes/PagosServiciosReport.tsx @@ -5,6 +5,7 @@ import { Card, Input, EmptyState, Skeleton, toast } from '../ui'; import { ExportButton } from '../ui/ExportButton'; import { exportToExcel } from '../../lib/exporter'; import { odooApi, type PagoServicioRow } from '../../services/odoo'; +import { useReportRange } from '../../lib/reportRange'; const formatCurrency = (value: number) => new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value); @@ -14,16 +15,10 @@ const metodoLabels: Record = { 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 [start, setStart] = useState(inicioDeMes()); - const [end, setEnd] = useState(hoy()); + // Rango global de Reportes; los campos Inicio/Fin fijan uno propio + const { start, end, setStart, setEnd } = useReportRange('pagos-servicios'); const [servicios, setServicios] = useState([]); const [byMethod, setByMethod] = useState>({}); const [totalCobrado, setTotalCobrado] = useState(0); @@ -90,6 +85,7 @@ const PagosServiciosReport: FC = () => {

setStart(e.target.value)} className="sm:max-w-[160px]" /> setEnd(e.target.value)} className="sm:max-w-[160px]" /> +
diff --git a/frontend/src/components/reportes/TopClientesReport.tsx b/frontend/src/components/reportes/TopClientesReport.tsx index a10b083..2d20b42 100644 --- a/frontend/src/components/reportes/TopClientesReport.tsx +++ b/frontend/src/components/reportes/TopClientesReport.tsx @@ -5,20 +5,15 @@ import { Card, Input, EmptyState, Skeleton, toast } from '../ui'; import { ExportButton } from '../ui/ExportButton'; import { exportToExcel } from '../../lib/exporter'; import { odooApi, type TopClienteRow } from '../../services/odoo'; +import { useReportRange } from '../../lib/reportRange'; const formatCurrency = (value: number) => 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 [start, setStart] = useState(inicioDeMes()); - const [end, setEnd] = useState(hoy()); + // Rango global de Reportes; los campos Inicio/Fin fijan uno propio + const { start, end, setStart, setEnd } = useReportRange('top-clientes'); const [clientes, setClientes] = useState([]); const [loading, setLoading] = useState(true); @@ -70,6 +65,7 @@ const TopClientesReport: FC = () => {
setStart(e.target.value)} className="sm:max-w-[160px]" /> setEnd(e.target.value)} className="sm:max-w-[160px]" /> +
diff --git a/frontend/src/components/reportes/VendedoresReport.tsx b/frontend/src/components/reportes/VendedoresReport.tsx index 52d58ad..816a298 100644 --- a/frontend/src/components/reportes/VendedoresReport.tsx +++ b/frontend/src/components/reportes/VendedoresReport.tsx @@ -5,20 +5,15 @@ import { Card, Input, EmptyState, Skeleton, toast } from '../ui'; import { ExportButton } from '../ui/ExportButton'; import { exportToExcel } from '../../lib/exporter'; import { odooApi, type VendedorRow } from '../../services/odoo'; +import { useReportRange } from '../../lib/reportRange'; const formatCurrency = (value: number) => 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 [start, setStart] = useState(inicioDeMes()); - const [end, setEnd] = useState(hoy()); + // Rango global de Reportes; los campos Inicio/Fin fijan uno propio + const { start, end, setStart, setEnd } = useReportRange('vendedores'); const [vendedores, setVendedores] = useState([]); const [loading, setLoading] = useState(true); @@ -75,6 +70,7 @@ const VendedoresReport: FC = () => {
setStart(e.target.value)} className="sm:max-w-[160px]" /> setEnd(e.target.value)} className="sm:max-w-[160px]" /> +
diff --git a/frontend/src/lib/reportRange.tsx b/frontend/src/lib/reportRange.tsx new file mode 100644 index 0000000..2308bcd --- /dev/null +++ b/frontend/src/lib/reportRange.tsx @@ -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; + 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; + setOverride: (key: string, range: DateRange | null) => void; +} + +const GlobalRangeContext = createContext(null); + +export const ReportRangeProvider: FC<{ children: ReactNode }> = ({ children }) => { + const [preset, setPreset] = useState(() => readPrefs()?.preset || 'mes'); + const [range, setRangeState] = useState(() => { + 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>({}); + + 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 {children}; +}; + +/** 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 ( + + ); +}; + +/** 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([]); + + 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) }; +}; diff --git a/frontend/src/pages/Configuracion.tsx b/frontend/src/pages/Configuracion.tsx index e66df74..d564d60 100644 --- a/frontend/src/pages/Configuracion.tsx +++ b/frontend/src/pages/Configuracion.tsx @@ -1,12 +1,14 @@ import type { FC } from 'react'; import { useEffect, useState } from 'react'; 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 UsuariosPanel from '../components/UsuariosPanel'; import RolesPanel from '../components/RolesPanel'; import RecetasPanel from '../components/RecetasPanel'; 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 { odooApi, type Doctor } from '../services/odoo'; @@ -16,6 +18,7 @@ const TABS = [ { key: 'roles', label: 'Roles' }, { key: 'recetas', label: 'Recetas' }, { key: 'catalogos', label: 'Catálogos' }, + { key: 'auditoria', label: 'Auditoría' }, ] as const; const SUBCATALOGOS = [ @@ -117,6 +120,7 @@ const Configuracion: FC = () => { const [commissionInputs, setCommissionInputs] = useState>({}); const [commissionSavingId, setCommissionSavingId] = useState(null); const [commissionSavedId, setCommissionSavedId] = useState(null); + const [impactoOpen, setImpactoOpen] = useState(false); useEffect(() => { odooApi.getCommissionDefault() @@ -212,6 +216,8 @@ const Configuracion: FC = () => { + ) : tab === 'auditoria' ? ( + ) : tab === 'catalogos' ? ( @@ -387,6 +393,18 @@ const Configuracion: FC = () => {

Porcentaje de comisión sobre las ventas atribuidas a cada médico (visible en su página "Mis ventas").

+
+
+

¿Cuánto costaría esta configuración?

+

+ Simula la comisión de cada médico con las ventas atribuidas reales de un mes, sin guardar nada. +

+
+ +
{
)} + + setImpactoOpen(false)} + comisionGlobal={parseFloat(commissionDefault) || 0} + /> ); }; diff --git a/frontend/src/pages/Reportes.tsx b/frontend/src/pages/Reportes.tsx index 49d53cd..bb92877 100644 --- a/frontend/src/pages/Reportes.tsx +++ b/frontend/src/pages/Reportes.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; 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 { BarChart, Bar, @@ -36,29 +36,89 @@ import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '.. import { odooApi } from '../services/odoo'; import type { CommissionRow } from '../services/odoo'; import { exportToExcel } from '../lib/exporter'; +import { + REPORT_PRESETS, + RangeOverrideHint, + ReportRangeProvider, + useGlobalReportRange, + useReportFavorites, +} from '../lib/reportRange'; const COLORS = ['#57534e', '#a8a29e', '#d6d3d1', '#78716c', '#f59e0b', '#10b981']; -const TABS = [ - { key: 'general', label: 'General' }, - { key: 'diario', label: 'Movimientos diarios' }, - { key: 'cortes', label: 'Cortes de caja' }, - { key: 'ingresos', label: 'Ingresos' }, - { key: 'inventario', label: 'Inventario' }, - { key: 'adeudos', label: 'Adeudos' }, - { key: 'comisiones', label: 'Comisiones' }, - { key: 'pagos-servicios', label: 'Pagos · Servicios' }, - { key: 'pagos-clientes', label: 'Pagos · Clientes' }, - { key: 'devoluciones', label: 'Devoluciones' }, - { key: 'top-clientes', label: 'Top clientes' }, - { key: 'exportar', label: 'Exportar' }, - { key: 'horas-agenda', label: 'Horas agenda' }, - { key: 'paquetes', label: 'Paquetes' }, - { key: 'vendedores', label: 'Vendedores' }, - { key: 'concentrado', label: 'Concentrado' }, - { key: 'recomendaciones', label: 'Recomendaciones' }, - { key: 'kpis', label: 'KPIs' }, -] as const; +interface ReportTab { + key: string; + label: string; + desc: string; +} + +interface ReportFamily { + key: string; + label: string; + hint: string; + icon: typeof Wallet; + tabs: ReportTab[]; +} + +// Familias de reportes: misma URL ?tab=xxx de siempre, navegación agrupada. +const FAMILIAS: ReportFamily[] = [ + { + key: 'dinero', + label: 'Dinero', + 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 = 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 { total_sales: number; @@ -77,16 +137,15 @@ interface CashReport { by_method: Record; } -const Reportes: FC = () => { +const ReportesContent: FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const tab = searchParams.get('tab') || 'general'; const setTab = (t: string) => setSearchParams({ tab: t }); - const [start, setStart] = useState(() => { - const d = new Date(); - d.setDate(1); - return d.toISOString().split('T')[0]; - }); - const [end, setEnd] = useState(() => new Date().toISOString().split('T')[0]); + const familiaKey = familiaDe(tab); + const familia = FAMILIAS.find((f) => f.key === familiaKey) || FAMILIAS[0]; + const { favs, toggle, isFav } = useReportFavorites(); + // Rango global del módulo (persistente, compartido por todas las pestañas) + const { start, end, preset, setRange } = useGlobalReportRange(); const [today, setToday] = useState(() => new Date().toISOString().split('T')[0]); const [salesReport, setSalesReport] = useState(null); const [appointmentsReport, setAppointmentsReport] = useState(null); @@ -96,6 +155,9 @@ const Reportes: FC = () => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const tabsFamilia = familia.tabs; + const tabInfo = useMemo(() => TODOS.find((t) => t.key === tab), [tab]); + useEffect(() => { if (tab !== 'general') return; const load = async () => { @@ -181,29 +243,158 @@ const Reportes: FC = () => { return ( - + {tab === 'general' && ( )} + - {/* Tabs de reportes */} -
- {TABS.map((t) => ( - - ))} + {/* Rango de fecha global (aplica a todas las pestañas) */} + + +
+ Periodo + {REPORT_PRESETS.map((p) => ( + + ))} +
+
+ setRange('custom', e.target.value, end)} + className="sm:max-w-[170px]" + /> + setRange('custom', start, e.target.value)} + className="sm:max-w-[170px]" + /> + +

+ Aplica a todas las pestañas. Cada reporte puede ajustar su propio rango con sus campos Inicio/Fin. +

+
+
+
+ + {/* Favoritos por usuario */} + {favs.length > 0 && ( +
+

+ + Favoritos +

+
+ {favs.map((k) => ( + + + + + ))} +
+
+ )} + + {/* Familias */} +
+ {FAMILIAS.map((f) => { + const Icono = f.icon; + const activa = f.key === familiaKey; + return ( + + ); + })} +
+ + {/* Pestañas de la familia activa */} +
+
+ {tabsFamilia.map((t) => { + const activo = tab === t.key; + return ( + + + + + ); + })} +
{tab === 'diario' ? ( @@ -245,20 +436,6 @@ const Reportes: FC = () => {
- setStart(e.target.value)} - className="sm:max-w-[180px]" - /> - setEnd(e.target.value)} - className="sm:max-w-[180px]" - /> { onChange={(e) => setToday(e.target.value)} className="sm:max-w-[180px]" /> +

+ Ventas, citas y comisiones usan el periodo global de arriba + ({start} a {end}). +

{error &&

{error}

} @@ -397,4 +578,11 @@ const Reportes: FC = () => { ); }; +/** Envuelve la vista con el proveedor del rango global. */ +const Reportes: FC = () => ( + + + +); + export default Reportes; diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts index 74131bb..f0545e7 100644 --- a/frontend/src/services/odoo.ts +++ b/frontend/src/services/odoo.ts @@ -260,6 +260,59 @@ export interface Service { 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 { id: number; name: string; @@ -1433,6 +1486,23 @@ export const odooApi = { 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 { + 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 { + const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/config/audit`, { params }); + return data; + }, + async getDoctorHistory(id: number): Promise { const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}/history`); return data; diff --git a/odoo-addons/skeen_whatsapp/controllers/frontend.py b/odoo-addons/skeen_whatsapp/controllers/frontend.py index a38a9d7..497bfa7 100644 --- a/odoo-addons/skeen_whatsapp/controllers/frontend.py +++ b/odoo-addons/skeen_whatsapp/controllers/frontend.py @@ -49,6 +49,33 @@ class SkeenFrontendController(http.Controller): return False 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): """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) user = User.create({'login': login, 'name': name, 'role': role, 'password_hash': 'pending'}) 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) except Exception as e: 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) vals['allowed_menus'] = json.dumps(menus) if menus else False if vals: + previos = {k: getattr(user, k) for k in 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 len(data['password']) < 8: return json_response({'status': 'error', 'message': 'La contraseña debe tener al menos 8 caracteres'}, 400) 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}}) except Exception as e: 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) if request.frontend_user and request.frontend_user.id == user.id: return json_response({'status': 'error', 'message': 'No puedes eliminar tu propio usuario'}, 400) + etiqueta = f'{user.login} ({user.name})' 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'}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @@ -248,7 +289,13 @@ class SkeenFrontendController(http.Controller): clean[href] = list(dict.fromkeys(roles)) if 'admin' not in clean.get('/configuracion', []): 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}) except Exception as e: 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) vals['staff_type'] = data['staff_type'] if vals: + previos = {k: getattr(doctor, k) for k in 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({ 'status': 'success', 'doctor': { @@ -2587,7 +2642,12 @@ class SkeenFrontendController(http.Controller): }, 400) vistos.add(dia) 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}) + 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({ 'status': 'success', 'horario': [{ @@ -2617,7 +2677,11 @@ class SkeenFrontendController(http.Controller): return json_response({'status': 'error', 'message': 'commission_pct debe ser numérico'}, 400) if not 0 <= pct <= 100: 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}) + if prev != pct: + self._auditar('comisiones', f'doctor:{doctor.id}', 'commission_pct', + prev, pct, entity_label=doctor.name) return json_response({ 'status': 'success', 'doctor': {'id': doctor.id, 'name': doctor.name, 'commission_pct': doctor.commission_pct}, @@ -4644,12 +4708,16 @@ class SkeenFrontendController(http.Controller): today = fields.Date.today() TC = request.env['skeen.tipo.cambio'].sudo() 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', '')} if existing: existing.write(vals) rec = existing else: 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({ 'status': 'success', '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) if not 0 <= pct <= 100: 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}) except Exception as e: 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 # ============================================================ diff --git a/odoo-addons/skeen_whatsapp/models/__init__.py b/odoo-addons/skeen_whatsapp/models/__init__.py index 19e2bd5..be86880 100644 --- a/odoo-addons/skeen_whatsapp/models/__init__.py +++ b/odoo-addons/skeen_whatsapp/models/__init__.py @@ -1,3 +1,4 @@ from . import main from . import wacrm_sync from . import frontend_user +from . import config_audit diff --git a/odoo-addons/skeen_whatsapp/models/config_audit.py b/odoo-addons/skeen_whatsapp/models/config_audit.py new file mode 100644 index 0000000..acd0e73 --- /dev/null +++ b/odoo-addons/skeen_whatsapp/models/config_audit.py @@ -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) diff --git a/odoo-addons/skeen_whatsapp/security/ir.model.access.csv b/odoo-addons/skeen_whatsapp/security/ir.model.access.csv index 89e96f0..d58d197 100644 --- a/odoo-addons/skeen_whatsapp/security/ir.model.access.csv +++ b/odoo-addons/skeen_whatsapp/security/ir.model.access.csv @@ -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_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_config_audit,skeen.config.audit,model_skeen_config_audit,base.group_user,1,1,1,1