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;
|
||||
|
||||
Reference in New Issue
Block a user