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