Rol medico: pagina Mis ventas con KPIs y comision estimada, menu restringido y bloqueo de endpoints de dinero global

This commit is contained in:
2026-08-31 03:56:48 +00:00
parent 009f84ce4a
commit 57efc3d9d3
10 changed files with 366 additions and 5 deletions

View File

@@ -0,0 +1,179 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { CircleDollarSign, CreditCard, ShoppingCart, Percent, DollarSign } from 'lucide-react';
import Layout from '../components/Layout';
import {
Card,
Select,
EmptyState,
PageHeader,
Skeleton,
toast,
} from '../components/ui';
import { odooApi, type MySalesResponse } from '../services/odoo';
const rangeOptions = [
{ value: '30d', label: 'Últimos 30 días' },
{ value: 'month', label: 'Este mes' },
{ value: 'last_month', label: 'Mes pasado' },
];
const mxn = (n: number) =>
`$${n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const MisVentas: FC = () => {
const [range, setRange] = useState<'30d' | 'month' | 'last_month'>('30d');
const [data, setData] = useState<MySalesResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await odooApi.getMySales(range);
if (res.status === 'success') setData(res);
} catch (err) {
setError('Error al cargar tus ventas');
toast.error('Error al cargar tus ventas');
console.error(err);
} finally {
setLoading(false);
}
}, [range]);
useEffect(() => {
load();
}, [load]);
const kpis = data?.kpis;
return (
<Layout title="Mis ventas" subtitle="Tus ventas y comisiones">
<PageHeader title="Mis ventas" subtitle="Ventas atribuidas a ti (líneas recetadas)">
<Select
options={rangeOptions}
value={range}
onChange={(e) => setRange(e.target.value as '30d' | 'month' | 'last_month')}
className="sm:max-w-[200px]"
/>
</PageHeader>
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
{loading || !data ? (
<Skeleton count={5} className="h-12 w-full" />
) : data.medico_sin_employee ? (
<Card>
<Card.Body>
<p className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-xl px-3 py-2 inline-block">
Tu usuario no está vinculado a un médico (hr.employee). Pide a un administrador que vincule tu usuario.
</p>
</Card.Body>
</Card>
) : (
<>
{/* KPIs del período */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<Card>
<Card.Body className="flex items-center gap-3">
<ShoppingCart size={20} className="text-theme-muted shrink-0" />
<div>
<p className="text-xs text-theme-muted">Ventas</p>
<p className="text-lg font-semibold text-theme-heading">{kpis?.ventas ?? 0}</p>
</div>
</Card.Body>
</Card>
<Card>
<Card.Body className="flex items-center gap-3">
<CircleDollarSign size={20} className="text-theme-muted shrink-0" />
<div>
<p className="text-xs text-theme-muted">Total MXN</p>
<p className="text-lg font-semibold text-theme-heading">{mxn(kpis?.total_mxn ?? 0)}</p>
</div>
</Card.Body>
</Card>
<Card>
<Card.Body className="flex items-center gap-3">
<DollarSign size={20} className="text-theme-muted shrink-0" />
<div>
<p className="text-xs text-theme-muted">Total USD</p>
<p className="text-lg font-semibold text-theme-heading">
{kpis?.total_usd != null ? `$${kpis.total_usd.toLocaleString('en-US', { minimumFractionDigits: 2 })}` : 'Sin TC'}
</p>
</div>
</Card.Body>
</Card>
<Card>
<Card.Body className="flex items-center gap-3">
<CreditCard size={20} className="text-theme-muted shrink-0" />
<div>
<p className="text-xs text-theme-muted">Con tarjeta</p>
<p className="text-lg font-semibold text-theme-heading">{mxn(kpis?.total_card ?? 0)}</p>
</div>
</Card.Body>
</Card>
</div>
{/* Comisión estimada */}
<Card className="mb-4">
<Card.Body className="flex flex-wrap items-center gap-3">
<Percent size={20} className="text-theme-muted shrink-0" />
<div className="flex-1 min-w-[200px]">
<p className="text-sm font-semibold text-theme-heading">
Comisión estimada: {mxn(data.commission_estimate)}
</p>
<p className="text-xs text-theme-muted">
{data.commission_pct}% {data.commission_source === 'empleado' ? '(tu porcentaje asignado)' : '(porcentaje global)'}
{' '}sobre {mxn(kpis?.total_mxn ?? 0)} del período
{data.date_from && data.date_to ? ` · ${data.date_from} a ${data.date_to}` : ''}
</p>
</div>
</Card.Body>
</Card>
{/* Tabla de ventas */}
<Card>
<Card.Body>
{data.sales.length === 0 ? (
<EmptyState
title="Sin ventas en el período"
subtitle="Las ventas se atribuyen por las líneas recetadas a tu nombre al cobrar."
/>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Fecha</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Venta</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Paciente</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Servicio / artículo</th>
<th className="text-right p-3 text-xs font-medium text-theme-muted uppercase">Monto</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Método de pago</th>
</tr>
</thead>
<tbody className="divide-y">
{data.sales.map((s) => (
<tr key={s.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm text-theme-heading whitespace-nowrap">{s.date}</td>
<td className="p-3 text-sm text-theme-muted">{s.name}</td>
<td className="p-3 text-sm font-medium text-theme-heading">{s.patient}</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{s.items.filter(Boolean).join(', ')}</td>
<td className="p-3 text-sm text-theme-heading text-right whitespace-nowrap">{mxn(s.amount)}</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{s.payment_method}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
</>
)}
</Layout>
);
};
export default MisVentas;