From 9aaa92733938b5136e631b5b0c9585cd995199c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Fri, 14 Aug 2026 09:24:34 +0000 Subject: [PATCH] =?UTF-8?q?M=C3=A9dicos:=20modal=20de=20detalle=20con=20ta?= =?UTF-8?q?b=20Info=20e=20Historial=20(citas,=20visitas,=20ventas=20receta?= =?UTF-8?q?das)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/Medicos.tsx | 261 +++++++++++++++++- frontend/src/services/odoo.ts | 45 +++ .../skeen_whatsapp/controllers/frontend.py | 64 +++++ 3 files changed, 365 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/Medicos.tsx b/frontend/src/pages/Medicos.tsx index 2f29542..13b5522 100644 --- a/frontend/src/pages/Medicos.tsx +++ b/frontend/src/pages/Medicos.tsx @@ -1,9 +1,9 @@ import type { FC } 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 { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, toast } from '../components/ui'; -import { odooApi, type Doctor } from '../services/odoo'; +import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, Modal, toast } from '../components/ui'; +import { odooApi, type Doctor, type DoctorHistory } from '../services/odoo'; 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) => (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 = { + 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 = { + en_curso: { label: 'En curso', variant: 'info' }, + completada: { label: 'Completada', variant: 'success' }, + cancelada: { label: 'Cancelada', variant: 'cancelled' }, +}; + +const VENTA_STATE: Record = { + 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; state: string }> = ({ map, state }) => { + const meta = map[state] || { label: state, variant: 'default' as BadgeVariant }; + return {meta.label}; +}; + /** Badges de ventas 30d al estilo del sistema legacy: * píldora verde "N ventas" + gris "N recomendaciones" y * píldoras índigo apiladas: usd / m.n. / tarjeta m.n. */ @@ -74,6 +111,25 @@ const Medicos: FC = () => { const [pctDraft, setPctDraft] = useState>({}); const [savingPct, setSavingPct] = useState(null); + // Modal de detalle del médico + const [selected, setSelected] = useState(null); + const [modalTab, setModalTab] = useState<'info' | 'historial'>('info'); + const [hist, setHist] = useState(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 savePct = async (d: Doctor) => { @@ -124,7 +180,7 @@ const Medicos: FC = () => { {doctors.map((d) => ( - + openDoctor(d)} title="Ver detalle del médico"> {d.name} @@ -148,7 +204,7 @@ const Medicos: FC = () => { '-' )} - + e.stopPropagation()}>
{ { label: '% Comisión', value: `${d.commission_pct ?? 0}%` }, { label: 'Últimos 30 días', value: }, ]} + actions={ + + } /> ))}
@@ -198,6 +259,196 @@ const Medicos: FC = () => { )} + + {/* Modal detalle del médico */} + setSelected(null)} title="" maxWidth="2xl"> + {selected && ( +
+ {/* Header */} +
+ + {initials(selected.name)} + +
+

{selected.name}

+ + + {selected.job_title || 'Sin puesto'} + +
+
+ + {/* Tabs */} +
+ {([{ key: 'info', label: 'Info' }, { key: 'historial', label: 'Historial' }] as const).map((t) => ( + + ))} +
+ + {modalTab === 'info' ? ( +
+
+
+

Teléfono

+

{selected.work_phone || '—'}

+
+
+

Email

+

{selected.work_email || '—'}

+
+
+

% Comisión

+

{selected.commission_pct ?? 0}%

+

Se edita inline en la tabla

+
+
+

Pacientes asignados

+

{selected.patient_count ?? 0}

+
+
+
+

Últimos 30 días

+ +
+
+ ) : ( +
+ {histLoading ? ( + + ) : !hist ? ( + + ) : ( + <> +

+ {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)} +

+
+ {([ + { key: 'citas', label: 'Citas', icon: }, + { key: 'visitas', label: 'Visitas', icon: }, + { key: 'ventas', label: 'Ventas recetadas', icon: }, + ] as const).map((s) => ( + + ))} +
+ + {histSection === 'citas' && ( + hist.citas.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {hist.citas.map((c) => ( + + + + + + + + ))} + +
FechaHoraPacienteServicioEstado
{c.date || '—'}{c.time || '—'}{c.patient}{c.service}
+
+ ) + )} + + {histSection === 'visitas' && ( + hist.visitas.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + {hist.visitas.map((v) => ( + + + + + + + ))} + +
FechaPacienteMotivoEstado
{v.date_start || '—'}{v.patient}{v.motivo || '—'}
+
+ ) + )} + + {histSection === 'ventas' && ( + hist.ventas.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + {hist.ventas.map((l) => ( + + + + + + + + + ))} + +
FolioFechaPacienteServicio / ArtículoSubtotalEstado
{l.venta}{l.date || '—'}{l.patient}{l.item}{fmtMoney(l.subtotal)}
+
+ ) + )} + + )} +
+ )} +
+ )} +
); }; diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts index b143a52..02b18fe 100644 --- a/frontend/src/services/odoo.ts +++ b/frontend/src/services/odoo.ts @@ -257,6 +257,46 @@ export interface Doctor { 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 { id: number; name: string; @@ -1090,6 +1130,11 @@ export const odooApi = { return data; }, + async getDoctorHistory(id: number): Promise { + const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}/history`); + return data; + }, + // Comisiones 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 } }); diff --git a/odoo-addons/skeen_whatsapp/controllers/frontend.py b/odoo-addons/skeen_whatsapp/controllers/frontend.py index 2292e0e..b51108a 100644 --- a/odoo-addons/skeen_whatsapp/controllers/frontend.py +++ b/odoo-addons/skeen_whatsapp/controllers/frontend.py @@ -1743,6 +1743,70 @@ class SkeenFrontendController(http.Controller): }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) + + @http.route('/skeen/frontend/v1/doctors//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: return json_response({'status': 'error', 'message': str(e)}, 500)