Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
This commit is contained in:
302
frontend/src/pages/Reportes.tsx
Normal file
302
frontend/src/pages/Reportes.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Download, BarChart3 } from 'lucide-react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import Layout from '../components/Layout';
|
||||
import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '../components/ui';
|
||||
import { odooApi } from '../services/odoo';
|
||||
import type { CommissionRow } from '../services/odoo';
|
||||
import { downloadCsv } from '../lib/utils';
|
||||
|
||||
const COLORS = ['#57534e', '#a8a29e', '#d6d3d1', '#78716c', '#f59e0b', '#10b981'];
|
||||
|
||||
interface SalesReport {
|
||||
total_sales: number;
|
||||
total_paid: number;
|
||||
total_due: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface AppointmentsReport {
|
||||
total: number;
|
||||
by_state: Record<string, number>;
|
||||
}
|
||||
|
||||
interface CashReport {
|
||||
total: number;
|
||||
by_method: Record<string, number>;
|
||||
}
|
||||
|
||||
const Reportes: FC = () => {
|
||||
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 [today, setToday] = useState(() => new Date().toISOString().split('T')[0]);
|
||||
const [salesReport, setSalesReport] = useState<SalesReport | null>(null);
|
||||
const [appointmentsReport, setAppointmentsReport] = useState<AppointmentsReport | null>(null);
|
||||
const [cashReport, setCashReport] = useState<CashReport | null>(null);
|
||||
const [commissions, setCommissions] = useState<CommissionRow[]>([]);
|
||||
const [commTotals, setCommTotals] = useState<{ base: number; commission: number }>({ base: 0, commission: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [salesRes, aptRes, cashRes, commRes] = await Promise.all([
|
||||
odooApi.getSalesReport(start, end),
|
||||
odooApi.getAppointmentsReport(start, end),
|
||||
odooApi.getCashReport(today),
|
||||
odooApi.getCommissions(start, end),
|
||||
]);
|
||||
if (salesRes.status === 'success')
|
||||
setSalesReport({
|
||||
total_sales: salesRes.total_sales,
|
||||
total_paid: salesRes.total_paid,
|
||||
total_due: salesRes.total_due,
|
||||
count: salesRes.count,
|
||||
});
|
||||
if (aptRes.status === 'success')
|
||||
setAppointmentsReport({ total: aptRes.total, by_state: aptRes.by_state });
|
||||
if (cashRes.status === 'success')
|
||||
setCashReport({ total: cashRes.total, by_method: cashRes.by_method });
|
||||
if (commRes.status === 'success') {
|
||||
setCommissions(commRes.commissions || []);
|
||||
setCommTotals({ base: commRes.total_base || 0, commission: commRes.total_commission || 0 });
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Error al cargar reportes');
|
||||
toast.error('Error al cargar reportes');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [start, end, today]);
|
||||
|
||||
const aptData = appointmentsReport
|
||||
? Object.entries(appointmentsReport.by_state).map(([name, value]) => ({ name, value }))
|
||||
: [];
|
||||
const cashData = cashReport
|
||||
? Object.entries(cashReport.by_method).map(([name, value]) => ({ name, value }))
|
||||
: [];
|
||||
|
||||
const exportCSV = () => {
|
||||
const rows: (string | number)[][] = [];
|
||||
rows.push(['Reporte SKEEN']);
|
||||
rows.push(['Rango', `${start} a ${end}`]);
|
||||
rows.push([]);
|
||||
|
||||
rows.push(['Ventas']);
|
||||
rows.push(['Total', 'Pagado', 'Por cobrar', 'Cantidad']);
|
||||
if (salesReport) {
|
||||
rows.push([
|
||||
salesReport.total_sales,
|
||||
salesReport.total_paid,
|
||||
salesReport.total_due,
|
||||
salesReport.count,
|
||||
]);
|
||||
}
|
||||
rows.push([]);
|
||||
|
||||
rows.push(['Citas por estado']);
|
||||
rows.push(['Estado', 'Cantidad']);
|
||||
aptData.forEach((row) => rows.push([row.name, row.value]));
|
||||
rows.push([]);
|
||||
|
||||
rows.push(['Efectivo por método']);
|
||||
rows.push(['Método', 'Monto']);
|
||||
cashData.forEach((row) => rows.push([row.name, row.value]));
|
||||
rows.push([]);
|
||||
|
||||
rows.push(['Comisiones por médico']);
|
||||
rows.push(['Médico', 'Puesto', '% Comisión', 'Artículos', 'Ventas', 'Base recetada', 'Comisión']);
|
||||
commissions.forEach((c) =>
|
||||
rows.push([c.doctor, c.job_title || '', c.commission_pct, c.items, c.sales, c.base, c.commission])
|
||||
);
|
||||
rows.push(['', '', '', '', 'Totales', commTotals.base, commTotals.commission]);
|
||||
|
||||
downloadCsv(`reporte-skeen-${start}_${end}.csv`, rows);
|
||||
toast.success('CSV descargado');
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Reportes" subtitle="Análisis del mes y día">
|
||||
<PageHeader title="Reportes" subtitle="Análisis del mes y día">
|
||||
<Button variant="outline" onClick={exportCSV} disabled={loading || !salesReport}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Exportar CSV
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<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"
|
||||
value={today}
|
||||
onChange={(e) => setToday(e.target.value)}
|
||||
className="sm:max-w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-rose-600 text-sm mt-4">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
||||
</div>
|
||||
) : salesReport ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Ventas</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">${salesReport.total_sales}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Pagado</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">${salesReport.total_paid}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Por cobrar</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">${salesReport.total_due}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Cantidad</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{salesReport.count}</p>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-4">Citas por estado</h3>
|
||||
{loading ? (
|
||||
<Skeleton className="h-[250px] w-full" />
|
||||
) : aptData.length === 0 ? (
|
||||
<EmptyState title="Sin datos" subtitle="No hay citas en el periodo." icon={<BarChart3 size={28} />} />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={aptData}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e7e5e4" />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} />
|
||||
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} />
|
||||
<Bar dataKey="value" fill="#57534e" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-4">Efectivo por método</h3>
|
||||
{loading ? (
|
||||
<Skeleton className="h-[250px] w-full" />
|
||||
) : cashData.length === 0 ? (
|
||||
<EmptyState title="Sin datos" subtitle="No hay pagos registrados para el día." icon={<BarChart3 size={28} />} />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie data={cashData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80}>
|
||||
{cashData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="mt-4 sm:mt-6">
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
|
||||
<div>
|
||||
<h3 className="font-heading text-xl text-theme-heading">Comisiones por médico</h3>
|
||||
<p className="text-xs text-theme-muted">Comisión = % del médico sobre la venta de artículos recetados por él.</p>
|
||||
</div>
|
||||
<div className="text-sm text-theme-muted">
|
||||
Base recetada: <span className="font-semibold text-theme-heading">${commTotals.base.toLocaleString('es-MX')}</span>
|
||||
{' · '}Comisión total: <span className="font-semibold text-theme-heading">${commTotals.commission.toLocaleString('es-MX')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : commissions.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">No hay ventas de artículos recetados en el periodo. Marca las líneas como “Artículo recetado” al crear la venta y asigna el % en Médicos.</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-4 py-3">Médico</th>
|
||||
<th className="text-right px-4 py-3">% Comisión</th>
|
||||
<th className="text-right px-4 py-3">Artículos</th>
|
||||
<th className="text-right px-4 py-3">Ventas</th>
|
||||
<th className="text-right px-4 py-3">Base recetada</th>
|
||||
<th className="text-right px-4 py-3">Comisión</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{commissions.map((c) => (
|
||||
<tr key={c.doctor_id} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-theme-heading">{c.doctor}</p>
|
||||
<p className="text-xs text-theme-muted">{c.job_title || '—'}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-theme-heading">{c.commission_pct}%</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{c.items}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{c.sales}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-heading">${c.base.toLocaleString('es-MX')}</td>
|
||||
<td className="px-4 py-3 text-right font-semibold text-theme-heading">${c.commission.toLocaleString('es-MX')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Reportes;
|
||||
Reference in New Issue
Block a user