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