Rol medico: pagina Mis ventas con KPIs y comision estimada, menu restringido y bloqueo de endpoints de dinero global
This commit is contained in:
@@ -23,6 +23,7 @@ import Inventario from './pages/Inventario';
|
||||
import WacrmMessages from './pages/WacrmMessages';
|
||||
import WacrmLeads from './pages/WacrmLeads';
|
||||
import Chat from './pages/Chat';
|
||||
import MisVentas from './pages/MisVentas';
|
||||
import Login from './pages/Login';
|
||||
import './App.css';
|
||||
|
||||
@@ -64,6 +65,7 @@ const App: FC = () => {
|
||||
<Route path="/login" element={<LoginRoute />} />
|
||||
<Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} />
|
||||
<Route path="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
|
||||
<Route path="/mis-ventas" element={<RequireAuth href="/mis-ventas"><MisVentas /></RequireAuth>} />
|
||||
<Route path="/pacientes" element={<RequireAuth href="/pacientes"><Pacientes /></RequireAuth>} />
|
||||
<Route path="/pos" element={<RequireAuth href="/pos"><Pos /></RequireAuth>} />
|
||||
<Route path="/visitas" element={<RequireAuth href="/visitas"><Visitas /></RequireAuth>} />
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
DollarSign,
|
||||
CircleDollarSign,
|
||||
} from 'lucide-react';
|
||||
import { Button, Modal, Input } from './ui';
|
||||
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
||||
@@ -133,6 +134,7 @@ const menuTree: MenuEntry[] = [
|
||||
{ label: 'Ventas del día', icon: ShoppingCart, href: '/ventas' },
|
||||
{ label: 'Punto de Venta', icon: Store, href: '/pos' },
|
||||
{ label: 'Clientes', icon: Users, href: '/pacientes' },
|
||||
{ label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' },
|
||||
];
|
||||
|
||||
// Hojas planas (para accesos rápidos y dock)
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
Pencil,
|
||||
ChevronUp,
|
||||
MessageCircle,
|
||||
CircleDollarSign,
|
||||
} from 'lucide-react';
|
||||
import { Button, Modal, Input } from './ui';
|
||||
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
||||
@@ -66,6 +67,7 @@ const menuSections = [
|
||||
{ label: 'Visitas', icon: ClipboardList, href: '/visitas' },
|
||||
{ label: 'Consultas', icon: ClipboardPlus, href: '/consultas' },
|
||||
{ label: 'Expedientes', icon: FolderClock, href: '/expedientes' },
|
||||
{ label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' },
|
||||
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
|
||||
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
|
||||
],
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
MessageCircle,
|
||||
CircleDollarSign,
|
||||
} from 'lucide-react';
|
||||
import { Button, Modal, Input } from './ui';
|
||||
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
||||
@@ -66,6 +67,7 @@ const menuGroups = [
|
||||
{ label: 'Visitas', icon: ClipboardList, href: '/visitas' },
|
||||
{ label: 'Consultas', icon: ClipboardPlus, href: '/consultas' },
|
||||
{ label: 'Expedientes', icon: FolderClock, href: '/expedientes' },
|
||||
{ label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' },
|
||||
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
|
||||
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
|
||||
],
|
||||
|
||||
@@ -23,6 +23,7 @@ const ROLE_RANK: Record<FrontendRole, number> = {
|
||||
const MENU_ROLES: Record<string, FrontendRole[]> = {
|
||||
'/': ['admin', 'recepcion', 'medico', 'lectura'],
|
||||
'/agenda': ['admin', 'recepcion', 'medico'],
|
||||
'/mis-ventas': ['medico'],
|
||||
'/pacientes': ['admin', 'recepcion', 'medico', 'lectura'],
|
||||
'/pos': ['admin', 'recepcion'],
|
||||
'/visitas': ['admin', 'recepcion', 'medico'],
|
||||
|
||||
179
frontend/src/pages/MisVentas.tsx
Normal file
179
frontend/src/pages/MisVentas.tsx
Normal 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;
|
||||
@@ -379,6 +379,31 @@ export interface Sale {
|
||||
refunded_at?: string | null;
|
||||
}
|
||||
|
||||
export interface MySaleItem {
|
||||
id: number;
|
||||
name: string;
|
||||
date: string;
|
||||
patient: string;
|
||||
items: string[];
|
||||
amount: number;
|
||||
payment_method: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface MySalesResponse {
|
||||
status: string;
|
||||
medico_sin_employee?: boolean;
|
||||
doctor?: { id: number; name: string };
|
||||
range?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
kpis: { ventas: number; total_mxn: number; total_usd: number | null; total_card: number };
|
||||
sales: MySaleItem[];
|
||||
commission_pct: number;
|
||||
commission_source?: string;
|
||||
commission_estimate: number;
|
||||
}
|
||||
|
||||
export interface CashClosing {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -1247,6 +1272,12 @@ export const odooApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// "Mis ventas" (rol medico): el servidor filtra por el employee del token
|
||||
async getMySales(range: '30d' | 'month' | 'last_month' = '30d'): Promise<MySalesResponse> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/my/sales`, { params: { range } });
|
||||
return data;
|
||||
},
|
||||
|
||||
async createSale(sale: Omit<Partial<Sale>, 'lines'> & { lines: Omit<SaleLine, 'subtotal'>[] }): Promise<{ status: string; sale: Sale }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/sales`, sale);
|
||||
return data;
|
||||
|
||||
Reference in New Issue
Block a user