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:
209
frontend/src/components/AuditoriaPanel.tsx
Normal file
209
frontend/src/components/AuditoriaPanel.tsx
Normal 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;
|
||||
216
frontend/src/components/ImpactoModal.tsx
Normal file
216
frontend/src/components/ImpactoModal.tsx
Normal 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;
|
||||
@@ -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<ComisionRow[]>([]);
|
||||
const [totales, setTotales] = useState({ vendido: 0, comision: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -79,6 +74,7 @@ const ComisionesReport: FC = () => {
|
||||
<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="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
||||
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
<p className="text-sm text-theme-muted">
|
||||
|
||||
@@ -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 }) => (
|
||||
<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 [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<ConcentradoData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -111,6 +106,7 @@ const ConcentradoReport: FC = () => {
|
||||
<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="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
||||
|
||||
<div className="sm:ml-auto">
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
|
||||
@@ -29,11 +29,15 @@ const metodoLabels: Record<string, string> = {
|
||||
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> = {
|
||||
compra: 'Compra', venta: 'Venta', baja: 'Baja', ajuste: 'Ajuste',
|
||||
};
|
||||
|
||||
const hoy = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
const DailyReport: FC = () => {
|
||||
const [fecha, setFecha] = useState(hoy());
|
||||
|
||||
@@ -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<DevolucionRow[]>([]);
|
||||
const [totalDevuelto, setTotalDevuelto] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -78,6 +73,7 @@ const DevolucionesReport: FC = () => {
|
||||
<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="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
||||
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
<p className="text-sm text-theme-muted">
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<HoraAgendaRow[]>([]);
|
||||
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">
|
||||
<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]" />
|
||||
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
<p className="text-sm text-theme-muted sm:ml-auto">{total} citas en el periodo</p>
|
||||
</div>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<SalesData | null>(null);
|
||||
const [byMethod, setByMethod] = useState<Record<string, number>>({});
|
||||
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">
|
||||
<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]" />
|
||||
|
||||
<div className="sm:ml-auto">
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
|
||||
@@ -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 }) => (
|
||||
<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 [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<KpisData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
@@ -102,6 +97,7 @@ const KpisReport: FC = () => {
|
||||
<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="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
||||
|
||||
<div className="sm:ml-auto">
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
|
||||
@@ -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<PagoClienteRow[]>([]);
|
||||
const [totalCobrado, setTotalCobrado] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -75,6 +70,7 @@ const PagosClientesReport: FC = () => {
|
||||
<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="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
||||
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
<p className="text-sm text-theme-muted">
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<PagoServicioRow[]>([]);
|
||||
const [byMethod, setByMethod] = useState<Record<string, number>>({});
|
||||
const [totalCobrado, setTotalCobrado] = useState(0);
|
||||
@@ -90,6 +85,7 @@ const PagosServiciosReport: FC = () => {
|
||||
<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="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
||||
|
||||
<div className="sm:ml-auto">
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
|
||||
@@ -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<TopClienteRow[]>([]);
|
||||
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">
|
||||
<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]" />
|
||||
|
||||
<div className="sm:ml-auto">
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
|
||||
@@ -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<VendedorRow[]>([]);
|
||||
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">
|
||||
<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]" />
|
||||
|
||||
<div className="sm:ml-auto">
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
|
||||
230
frontend/src/lib/reportRange.tsx
Normal file
230
frontend/src/lib/reportRange.tsx
Normal 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) };
|
||||
};
|
||||
@@ -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<Record<number, string>>({});
|
||||
const [commissionSavingId, setCommissionSavingId] = useState<number | null>(null);
|
||||
const [commissionSavedId, setCommissionSavedId] = useState<number | null>(null);
|
||||
const [impactoOpen, setImpactoOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
odooApi.getCommissionDefault()
|
||||
@@ -212,6 +216,8 @@ const Configuracion: FC = () => {
|
||||
<RecetasPanel />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'auditoria' ? (
|
||||
<AuditoriaPanel />
|
||||
) : tab === 'catalogos' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
@@ -387,6 +393,18 @@ const Configuracion: FC = () => {
|
||||
<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").
|
||||
</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-1 w-full">
|
||||
<Input
|
||||
@@ -467,6 +485,12 @@ const Configuracion: FC = () => {
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ImpactoModal
|
||||
isOpen={impactoOpen}
|
||||
onClose={() => setImpactoOpen(false)}
|
||||
comisionGlobal={parseFloat(commissionDefault) || 0}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<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 {
|
||||
total_sales: number;
|
||||
@@ -77,16 +137,15 @@ interface CashReport {
|
||||
by_method: Record<string, number>;
|
||||
}
|
||||
|
||||
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<SalesReport | null>(null);
|
||||
const [appointmentsReport, setAppointmentsReport] = useState<AppointmentsReport | null>(null);
|
||||
@@ -96,6 +155,9 @@ const Reportes: FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<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' && (
|
||||
<Button variant="outline" onClick={exportExcel} disabled={loading || !salesReport} loading={exporting}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Exportar
|
||||
</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>
|
||||
|
||||
{/* Tabs de reportes */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-6">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
|
||||
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
{/* Rango de fecha global (aplica a todas las pestañas) */}
|
||||
<Card className="mb-4">
|
||||
<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
|
||||
key={p.value}
|
||||
type="button"
|
||||
onClick={() => setRange(p.value)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</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' ? (
|
||||
@@ -245,20 +436,6 @@ const Reportes: FC = () => {
|
||||
<Card className="mb-6">
|
||||
<Card.Body>
|
||||
<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
|
||||
label="Día efectivo"
|
||||
type="date"
|
||||
@@ -266,6 +443,10 @@ const Reportes: FC = () => {
|
||||
onChange={(e) => setToday(e.target.value)}
|
||||
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>
|
||||
|
||||
{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;
|
||||
|
||||
@@ -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<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> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}/history`);
|
||||
return data;
|
||||
|
||||
@@ -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
|
||||
# ============================================================
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from . import main
|
||||
from . import wacrm_sync
|
||||
from . import frontend_user
|
||||
from . import config_audit
|
||||
|
||||
60
odoo-addons/skeen_whatsapp/models/config_audit.py
Normal file
60
odoo-addons/skeen_whatsapp/models/config_audit.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
|
Reference in New Issue
Block a user