Médicos: modal de detalle con tab Info e Historial (citas, visitas, ventas recetadas)
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Mail, Phone, Stethoscope, Briefcase, Save, Percent } from 'lucide-react';
|
import { Mail, Phone, Stethoscope, Briefcase, Save, Percent, Eye, CalendarDays, FileText, ShoppingCart, Users } from 'lucide-react';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, toast } from '../components/ui';
|
import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, Modal, toast } from '../components/ui';
|
||||||
import { odooApi, type Doctor } from '../services/odoo';
|
import { odooApi, type Doctor, type DoctorHistory } from '../services/odoo';
|
||||||
|
|
||||||
const medicalTerms = ['doctor', 'dra', 'médico', 'medico', 'especialista', 'dermatólogo', 'dermatologo', 'skin', 'clínico', 'clinico'];
|
const medicalTerms = ['doctor', 'dra', 'médico', 'medico', 'especialista', 'dermatólogo', 'dermatologo', 'skin', 'clínico', 'clinico'];
|
||||||
|
|
||||||
@@ -16,6 +16,43 @@ const isMedicalTitle = (jobTitle?: string) => {
|
|||||||
const fmtNum = (n: number | null | undefined, decimals = 2) =>
|
const fmtNum = (n: number | null | undefined, decimals = 2) =>
|
||||||
(n ?? 0).toLocaleString('es-MX', { maximumFractionDigits: decimals });
|
(n ?? 0).toLocaleString('es-MX', { maximumFractionDigits: decimals });
|
||||||
|
|
||||||
|
const fmtMoney = (n: number) =>
|
||||||
|
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
const initials = (name: string) =>
|
||||||
|
name.split(' ').filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase();
|
||||||
|
|
||||||
|
type BadgeVariant = 'default' | 'success' | 'info' | 'pending' | 'cancelled';
|
||||||
|
|
||||||
|
const CITA_STATE: Record<string, { label: string; variant: BadgeVariant }> = {
|
||||||
|
pending: { label: 'Pendiente', variant: 'pending' },
|
||||||
|
confirmed: { label: 'Confirmada', variant: 'info' },
|
||||||
|
arrived: { label: 'Llegó', variant: 'info' },
|
||||||
|
in_progress: { label: 'En progreso', variant: 'info' },
|
||||||
|
done: { label: 'Completada', variant: 'success' },
|
||||||
|
cancelled: { label: 'Cancelada', variant: 'cancelled' },
|
||||||
|
no_show: { label: 'No show', variant: 'cancelled' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const VISITA_STATE: Record<string, { label: string; variant: BadgeVariant }> = {
|
||||||
|
en_curso: { label: 'En curso', variant: 'info' },
|
||||||
|
completada: { label: 'Completada', variant: 'success' },
|
||||||
|
cancelada: { label: 'Cancelada', variant: 'cancelled' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const VENTA_STATE: Record<string, { label: string; variant: BadgeVariant }> = {
|
||||||
|
draft: { label: 'Borrador', variant: 'default' },
|
||||||
|
confirmed: { label: 'Confirmada', variant: 'info' },
|
||||||
|
paid: { label: 'Pagada', variant: 'success' },
|
||||||
|
partial: { label: 'Parcial', variant: 'pending' },
|
||||||
|
cancelled: { label: 'Cancelada', variant: 'cancelled' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const StateBadge: FC<{ map: Record<string, { label: string; variant: BadgeVariant }>; state: string }> = ({ map, state }) => {
|
||||||
|
const meta = map[state] || { label: state, variant: 'default' as BadgeVariant };
|
||||||
|
return <Badge variant={meta.variant}>{meta.label}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
/** Badges de ventas 30d al estilo del sistema legacy:
|
/** Badges de ventas 30d al estilo del sistema legacy:
|
||||||
* píldora verde "N ventas" + gris "N recomendaciones" y
|
* píldora verde "N ventas" + gris "N recomendaciones" y
|
||||||
* píldoras índigo apiladas: usd / m.n. / tarjeta m.n. */
|
* píldoras índigo apiladas: usd / m.n. / tarjeta m.n. */
|
||||||
@@ -74,6 +111,25 @@ const Medicos: FC = () => {
|
|||||||
const [pctDraft, setPctDraft] = useState<Record<number, string>>({});
|
const [pctDraft, setPctDraft] = useState<Record<number, string>>({});
|
||||||
const [savingPct, setSavingPct] = useState<number | null>(null);
|
const [savingPct, setSavingPct] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Modal de detalle del médico
|
||||||
|
const [selected, setSelected] = useState<Doctor | null>(null);
|
||||||
|
const [modalTab, setModalTab] = useState<'info' | 'historial'>('info');
|
||||||
|
const [hist, setHist] = useState<DoctorHistory | null>(null);
|
||||||
|
const [histLoading, setHistLoading] = useState(false);
|
||||||
|
const [histSection, setHistSection] = useState<'citas' | 'visitas' | 'ventas'>('citas');
|
||||||
|
|
||||||
|
const openDoctor = (d: Doctor) => {
|
||||||
|
setSelected(d);
|
||||||
|
setModalTab('info');
|
||||||
|
setHistSection('citas');
|
||||||
|
setHist(null);
|
||||||
|
setHistLoading(true);
|
||||||
|
odooApi.getDoctorHistory(d.id)
|
||||||
|
.then((res) => { if (res.status === 'success') setHist(res); })
|
||||||
|
.catch((err) => { toast.error('No se pudo cargar el historial'); console.error(err); })
|
||||||
|
.finally(() => setHistLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
const pctValue = (d: Doctor) => (pctDraft[d.id] ?? String(d.commission_pct ?? 0));
|
const pctValue = (d: Doctor) => (pctDraft[d.id] ?? String(d.commission_pct ?? 0));
|
||||||
|
|
||||||
const savePct = async (d: Doctor) => {
|
const savePct = async (d: Doctor) => {
|
||||||
@@ -124,7 +180,7 @@ const Medicos: FC = () => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
{doctors.map((d) => (
|
{doctors.map((d) => (
|
||||||
<tr key={d.id} className="hover:bg-theme-bg">
|
<tr key={d.id} className="hover:bg-theme-bg cursor-pointer" onClick={() => openDoctor(d)} title="Ver detalle del médico">
|
||||||
<td className="p-3 text-sm font-medium text-theme-heading">{d.name}</td>
|
<td className="p-3 text-sm font-medium text-theme-heading">{d.name}</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Badge variant="default" className="inline-flex items-center">
|
<Badge variant="default" className="inline-flex items-center">
|
||||||
@@ -148,7 +204,7 @@ const Medicos: FC = () => {
|
|||||||
'-'
|
'-'
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center gap-2 max-w-[170px]">
|
<div className="flex items-center gap-2 max-w-[170px]">
|
||||||
<Percent size={14} className="text-theme-muted shrink-0" />
|
<Percent size={14} className="text-theme-muted shrink-0" />
|
||||||
<Input
|
<Input
|
||||||
@@ -191,6 +247,11 @@ const Medicos: FC = () => {
|
|||||||
{ label: '% Comisión', value: `${d.commission_pct ?? 0}%` },
|
{ label: '% Comisión', value: `${d.commission_pct ?? 0}%` },
|
||||||
{ label: 'Últimos 30 días', value: <StatBadges d={d} /> },
|
{ label: 'Últimos 30 días', value: <StatBadges d={d} /> },
|
||||||
]}
|
]}
|
||||||
|
actions={
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openDoctor(d)} title="Ver detalle">
|
||||||
|
<Eye size={16} className="text-theme-muted" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -198,6 +259,196 @@ const Medicos: FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Modal detalle del médico */}
|
||||||
|
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="" maxWidth="2xl">
|
||||||
|
{selected && (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-5">
|
||||||
|
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-sm font-bold text-theme-heading">
|
||||||
|
{initials(selected.name)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-heading text-xl text-theme-heading truncate">{selected.name}</h3>
|
||||||
|
<Badge variant="default" className="inline-flex items-center mt-1">
|
||||||
|
<Briefcase size={12} className="mr-1" />
|
||||||
|
{selected.job_title || 'Sin puesto'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-4">
|
||||||
|
{([{ key: 'info', label: 'Info' }, { key: 'historial', label: 'Historial' }] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setModalTab(t.key)}
|
||||||
|
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
|
||||||
|
modalTab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modalTab === 'info' ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div className="p-3 bg-theme-bg rounded-xl">
|
||||||
|
<p className="text-xs text-theme-muted mb-0.5">Teléfono</p>
|
||||||
|
<p className="text-sm text-theme-heading flex items-center"><Phone size={14} className="mr-1.5 text-theme-muted" />{selected.work_phone || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-theme-bg rounded-xl">
|
||||||
|
<p className="text-xs text-theme-muted mb-0.5">Email</p>
|
||||||
|
<p className="text-sm text-theme-heading flex items-center truncate"><Mail size={14} className="mr-1.5 text-theme-muted shrink-0" />{selected.work_email || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-theme-bg rounded-xl">
|
||||||
|
<p className="text-xs text-theme-muted mb-0.5">% Comisión</p>
|
||||||
|
<p className="text-sm text-theme-heading">{selected.commission_pct ?? 0}%</p>
|
||||||
|
<p className="text-xs text-theme-muted mt-0.5">Se edita inline en la tabla</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-theme-bg rounded-xl">
|
||||||
|
<p className="text-xs text-theme-muted mb-0.5">Pacientes asignados</p>
|
||||||
|
<p className="text-sm text-theme-heading flex items-center"><Users size={14} className="mr-1.5 text-theme-muted" />{selected.patient_count ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-theme-bg rounded-xl">
|
||||||
|
<p className="text-xs text-theme-muted mb-2">Últimos 30 días</p>
|
||||||
|
<StatBadges d={selected} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{histLoading ? (
|
||||||
|
<Skeleton count={4} className="h-10 w-full" />
|
||||||
|
) : !hist ? (
|
||||||
|
<EmptyState title="Sin historial" subtitle="No se pudo cargar la actividad del médico." />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-theme-muted mb-3">
|
||||||
|
{hist.totales.citas_total} citas en total · {hist.totales.visitas_total} visitas · {hist.totales.ventas_total_lineas} líneas recetadas por {fmtMoney(hist.totales.monto_total)}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||||
|
{([
|
||||||
|
{ key: 'citas', label: 'Citas', icon: <CalendarDays size={14} className="mr-1" /> },
|
||||||
|
{ key: 'visitas', label: 'Visitas', icon: <FileText size={14} className="mr-1" /> },
|
||||||
|
{ key: 'ventas', label: 'Ventas recetadas', icon: <ShoppingCart size={14} className="mr-1" /> },
|
||||||
|
] as const).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setHistSection(s.key)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-full transition inline-flex items-center ${
|
||||||
|
histSection === s.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.icon}{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{histSection === 'citas' && (
|
||||||
|
hist.citas.length === 0 ? (
|
||||||
|
<EmptyState title="Sin citas" subtitle="Este médico no tiene citas registradas." />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto max-h-[50vh] overflow-y-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">Fecha</th>
|
||||||
|
<th className="text-left px-3 py-2">Hora</th>
|
||||||
|
<th className="text-left px-3 py-2">Paciente</th>
|
||||||
|
<th className="text-left px-3 py-2">Servicio</th>
|
||||||
|
<th className="text-left px-3 py-2">Estado</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{hist.citas.map((c) => (
|
||||||
|
<tr key={c.id} className="hover:bg-theme-bg">
|
||||||
|
<td className="px-3 py-2 text-theme-heading whitespace-nowrap">{c.date || '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-theme-muted">{c.time || '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-theme-heading">{c.patient}</td>
|
||||||
|
<td className="px-3 py-2 text-theme-muted">{c.service}</td>
|
||||||
|
<td className="px-3 py-2"><StateBadge map={CITA_STATE} state={c.state} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{histSection === 'visitas' && (
|
||||||
|
hist.visitas.length === 0 ? (
|
||||||
|
<EmptyState title="Sin visitas" subtitle="Este médico no tiene visitas registradas." />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto max-h-[50vh] overflow-y-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">Fecha</th>
|
||||||
|
<th className="text-left px-3 py-2">Paciente</th>
|
||||||
|
<th className="text-left px-3 py-2">Motivo</th>
|
||||||
|
<th className="text-left px-3 py-2">Estado</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{hist.visitas.map((v) => (
|
||||||
|
<tr key={v.id} className="hover:bg-theme-bg">
|
||||||
|
<td className="px-3 py-2 text-theme-heading whitespace-nowrap">{v.date_start || '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-theme-heading">{v.patient}</td>
|
||||||
|
<td className="px-3 py-2 text-theme-muted">{v.motivo || '—'}</td>
|
||||||
|
<td className="px-3 py-2"><StateBadge map={VISITA_STATE} state={v.state} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{histSection === 'ventas' && (
|
||||||
|
hist.ventas.length === 0 ? (
|
||||||
|
<EmptyState title="Sin ventas recetadas" subtitle="Este médico no tiene líneas recetadas." />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto max-h-[50vh] overflow-y-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">Folio</th>
|
||||||
|
<th className="text-left px-3 py-2">Fecha</th>
|
||||||
|
<th className="text-left px-3 py-2">Paciente</th>
|
||||||
|
<th className="text-left px-3 py-2">Servicio / Artículo</th>
|
||||||
|
<th className="text-right px-3 py-2">Subtotal</th>
|
||||||
|
<th className="text-left px-3 py-2">Estado</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{hist.ventas.map((l) => (
|
||||||
|
<tr key={l.id} className="hover:bg-theme-bg">
|
||||||
|
<td className="px-3 py-2 text-theme-heading whitespace-nowrap">{l.venta}</td>
|
||||||
|
<td className="px-3 py-2 text-theme-muted whitespace-nowrap">{l.date || '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-theme-heading">{l.patient}</td>
|
||||||
|
<td className="px-3 py-2 text-theme-muted">{l.item}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-theme-heading">{fmtMoney(l.subtotal)}</td>
|
||||||
|
<td className="px-3 py-2"><StateBadge map={VENTA_STATE} state={l.state} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -257,6 +257,46 @@ export interface Doctor {
|
|||||||
card_30d?: number;
|
card_30d?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DoctorHistoryCita {
|
||||||
|
id: number;
|
||||||
|
date: string | null;
|
||||||
|
time: string;
|
||||||
|
patient: string;
|
||||||
|
service: string;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DoctorHistoryVisita {
|
||||||
|
id: number;
|
||||||
|
date_start: string | null;
|
||||||
|
patient: string;
|
||||||
|
motivo: string;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DoctorHistoryVenta {
|
||||||
|
id: number;
|
||||||
|
venta: string;
|
||||||
|
date: string | null;
|
||||||
|
patient: string;
|
||||||
|
item: string;
|
||||||
|
subtotal: number;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DoctorHistory {
|
||||||
|
status: string;
|
||||||
|
citas: DoctorHistoryCita[];
|
||||||
|
visitas: DoctorHistoryVisita[];
|
||||||
|
ventas: DoctorHistoryVenta[];
|
||||||
|
totales: {
|
||||||
|
citas_total: number;
|
||||||
|
visitas_total: number;
|
||||||
|
ventas_total_lineas: number;
|
||||||
|
monto_total: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1090,6 +1130,11 @@ export const odooApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getDoctorHistory(id: number): Promise<DoctorHistory> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}/history`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
// Comisiones
|
// Comisiones
|
||||||
async getCommissions(start?: string, end?: string): Promise<{ status: string; start: string; end: string; total_base: number; total_commission: number; commissions: CommissionRow[] }> {
|
async getCommissions(start?: string, end?: string): Promise<{ status: string; start: string; end: string; total_base: number; total_commission: number; commissions: CommissionRow[] }> {
|
||||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/commissions`, { params: { start, end } });
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/commissions`, { params: { start, end } });
|
||||||
|
|||||||
@@ -1743,6 +1743,70 @@ class SkeenFrontendController(http.Controller):
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/doctors/<int:doctor_id>/history', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
def doctor_history(self, doctor_id, **kw):
|
||||||
|
"""Historial de actividad del médico: citas, visitas y líneas recetadas.
|
||||||
|
|
||||||
|
Devuelve las últimas 50 de cada lista (más recientes primero) y
|
||||||
|
`totales` sobre TODO el historial (no solo las 50).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
doctor = request.env['hr.employee'].sudo().browse(doctor_id)
|
||||||
|
if not doctor.exists():
|
||||||
|
return json_response({'status': 'error', 'message': 'Médico no encontrado'}, 404)
|
||||||
|
|
||||||
|
Citas = request.env['skeen.cita'].sudo()
|
||||||
|
citas = Citas.search([('doctor_id', '=', doctor_id)], order='date desc, time desc', limit=50)
|
||||||
|
citas_total = Citas.search_count([('doctor_id', '=', doctor_id)])
|
||||||
|
|
||||||
|
Visitas = request.env['skeen.visita'].sudo()
|
||||||
|
visitas = Visitas.search([('doctor_id', '=', doctor_id)], order='date_start desc', limit=50)
|
||||||
|
visitas_total = Visitas.search_count([('doctor_id', '=', doctor_id)])
|
||||||
|
|
||||||
|
Lineas = request.env['skeen.venta.line'].sudo()
|
||||||
|
line_domain = [('prescribed_by_id', '=', doctor_id)]
|
||||||
|
lineas = Lineas.search(line_domain, order='id desc', limit=50)
|
||||||
|
agg = Lineas.read_group(line_domain, ['subtotal:sum'], [])
|
||||||
|
ventas_total_lineas = agg[0]['__count'] if agg else 0
|
||||||
|
monto_total = (agg[0]['subtotal'] or 0.0) if agg else 0.0
|
||||||
|
|
||||||
|
return json_response({
|
||||||
|
'status': 'success',
|
||||||
|
'citas': [{
|
||||||
|
'id': c.id,
|
||||||
|
'date': c.date.strftime('%Y-%m-%d') if c.date else None,
|
||||||
|
'time': c.time_str or '',
|
||||||
|
'patient': c.partner_id.name,
|
||||||
|
'service': c.servicio_id.name,
|
||||||
|
'state': c.state,
|
||||||
|
} for c in citas],
|
||||||
|
'visitas': [{
|
||||||
|
'id': v.id,
|
||||||
|
'date_start': v.date_start.strftime('%Y-%m-%d %H:%M') if v.date_start else None,
|
||||||
|
'patient': v.partner_id.name,
|
||||||
|
'motivo': v.motivo or '',
|
||||||
|
'state': v.state,
|
||||||
|
} for v in visitas],
|
||||||
|
'ventas': [{
|
||||||
|
'id': l.id,
|
||||||
|
'venta': l.venta_id.name,
|
||||||
|
'date': l.venta_id.date.strftime('%Y-%m-%d') if l.venta_id.date else None,
|
||||||
|
'patient': l.venta_id.partner_id.name,
|
||||||
|
'item': l.description or (l.service_id.name if l.service_id else (l.item_id.name if l.item_id else '')),
|
||||||
|
'subtotal': l.subtotal,
|
||||||
|
'state': l.venta_id.state,
|
||||||
|
} for l in lineas],
|
||||||
|
'totales': {
|
||||||
|
'citas_total': citas_total,
|
||||||
|
'visitas_total': visitas_total,
|
||||||
|
'ventas_total_lineas': ventas_total_lineas,
|
||||||
|
'monto_total': round(monto_total, 2),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user