From 09b4f25bb81519c8dea5980a5960b1965ddf34d1 Mon Sep 17 00:00:00 2001 From: Kimi Date: Thu, 24 Sep 2026 16:03:02 +0000 Subject: [PATCH] =?UTF-8?q?Punto=2013:=20alta/baja=20de=20m=C3=A9dicos=20d?= =?UTF-8?q?esde=20el=20m=C3=B3dulo=20M=C3=A9dicos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Columna 'Estado' con switch Activo/Baja (confirmación previa) y checkbox 'Mostrar dados de baja' para poder reactivar. - GET /doctors solo devuelve activos; include_inactive=1 para la pantalla de Médicos. Cada médico expone su campo 'active'. - Reglas en modelo: un empleado dado de baja no admite citas nuevas (skeen.cita), ni asignación de pacientes (res.partner.primary_doctor_id), ni líneas de venta (skeen.venta.line.prescribed_by_id). El historial existente no se toca: los constraints solo corren cuando cambia la asignación. - ValidationError se responde 400 con mensaje legible en pacientes y ventas. - fix: badges de estado de cita en agenda y dashboard usan la etiqueta de la clínica en lugar del valor técnico. --- frontend/src/pages/Medicos.tsx | 83 +++++++++++++++++-- frontend/src/services/odoo.ts | 10 ++- odoo-addons/skeen_citas/models/cita.py | 15 ++++ .../skeen_pacientes/models/res_partner.py | 18 +++- odoo-addons/skeen_ventas/models/venta.py | 11 +++ .../skeen_whatsapp/controllers/frontend.py | 19 ++++- 6 files changed, 146 insertions(+), 10 deletions(-) diff --git a/frontend/src/pages/Medicos.tsx b/frontend/src/pages/Medicos.tsx index 22a72b3..3152458 100644 --- a/frontend/src/pages/Medicos.tsx +++ b/frontend/src/pages/Medicos.tsx @@ -133,13 +133,17 @@ const Medicos: FC = () => { const [employees, setEmployees] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Los dados de baja se cargan siempre: se ocultan en la tabla salvo que el + // usuario pida verlos (para poder reactivarlos). + const [mostrarBaja, setMostrarBaja] = useState(false); + const [savingActive, setSavingActive] = useState(null); useEffect(() => { const load = async () => { try { setLoading(true); setError(null); - const res = await odooApi.getDoctors(); + const res = await odooApi.getDoctors({ includeInactive: true }); if (res.status === 'success') setEmployees(res.doctors); } catch (err) { setError('Error al cargar médicos'); @@ -153,9 +157,33 @@ const Medicos: FC = () => { }, []); const doctors = useMemo(() => { - const medical = employees.filter((d) => isMedicalTitle(d.job_title)); - return medical.length > 0 ? medical : employees; - }, [employees]); + const visibles = mostrarBaja ? employees : employees.filter((d) => d.active !== false); + const medical = visibles.filter((d) => isMedicalTitle(d.job_title)); + return medical.length > 0 ? medical : visibles; + }, [employees, mostrarBaja]); + + const countBaja = employees.filter((d) => d.active === false).length; + + // Activar/desactivar: un médico dado de baja desaparece de la agenda, del + // selector de pacientes y del de ventas (el backend lo valida también). + const toggleActivo = async (d: Doctor) => { + const nuevo = d.active === false; + const mensaje = nuevo + ? `¿Reactivar a ${d.name}? Volverá a aparecer en la agenda y en las asignaciones.` + : `¿Dar de baja a ${d.name}? Dejará de aparecer en la agenda y no se le podrán asignar pacientes ni ventas. Su historial se conserva.`; + if (!window.confirm(mensaje)) return; + try { + setSavingActive(d.id); + await odooApi.updateDoctor(d.id, { active: nuevo }); + setEmployees((prev) => prev.map((e) => (e.id === d.id ? { ...e, active: nuevo } : e))); + toast.success(nuevo ? `${d.name} reactivado` : `${d.name} dado de baja`); + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'No se pudo actualizar el estado'); + } finally { + setSavingActive(null); + } + }; const [pctDraft, setPctDraft] = useState>({}); const [savingPct, setSavingPct] = useState(null); @@ -299,7 +327,19 @@ const Medicos: FC = () => { return ( - + + {countBaja > 0 && ( + + )} + @@ -318,6 +358,7 @@ const Medicos: FC = () => { Nombre Puesto Tipo + Estado Teléfono Email % Comisión @@ -348,6 +389,24 @@ const Medicos: FC = () => { className="max-w-[150px]" /> + e.stopPropagation()}> + + @@ -414,6 +473,20 @@ const Medicos: FC = () => { } rows={[ + { + label: 'Estado', + value: ( + + ), + }, { label: 'Teléfono', value: d.work_phone || '-' }, { label: 'Email', value: d.work_email || '-' }, { label: '% Comisión', value: `${d.commission_pct ?? 0}%` }, diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts index d0ef8fa..20ed9af 100644 --- a/frontend/src/services/odoo.ts +++ b/frontend/src/services/odoo.ts @@ -371,6 +371,8 @@ export interface ConfigAuditResponse { export interface Doctor { id: number; name: string; + /** false = dado de baja: no aparece en agenda, pacientes ni ventas */ + active?: boolean; job_title: string; work_phone: string; work_email: string; @@ -1658,12 +1660,14 @@ export const odooApi = { }, // Médicos - async getDoctors(): Promise<{ status: string; doctors: Doctor[] }> { - const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/doctors`); + async getDoctors(opts?: { includeInactive?: boolean }): Promise<{ status: string; doctors: Doctor[] }> { + const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/doctors`, { + params: opts?.includeInactive ? { include_inactive: 1 } : undefined, + }); return data; }, - async updateDoctor(id: number, payload: { commission_pct?: number; staff_type?: 'doctor' | 'cosmetologa'; monthly_goal?: number }): Promise<{ status: string; doctor: Doctor }> { + async updateDoctor(id: number, payload: { commission_pct?: number; staff_type?: 'doctor' | 'cosmetologa'; monthly_goal?: number; active?: boolean }): Promise<{ status: string; doctor: Doctor }> { const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}`, payload); return data; }, diff --git a/odoo-addons/skeen_citas/models/cita.py b/odoo-addons/skeen_citas/models/cita.py index 839134f..bc9cdb0 100644 --- a/odoo-addons/skeen_citas/models/cita.py +++ b/odoo-addons/skeen_citas/models/cita.py @@ -406,6 +406,21 @@ class SkeenCita(models.Model): if cita.duration and (cita.duration < 5 or cita.duration > 480): raise ValidationError(_('La duración debe estar entre 5 y 480 minutos')) + @api.constrains('doctor_id') + def _check_doctor_activo(self): + """Un médico dado de baja no puede recibir citas nuevas. + + Solo corre cuando cambia `doctor_id`, así que el historial de un médico + desactivado sigue intacto y editable (estado, pagos, notas). + """ + for cita in self: + if cita.doctor_id and not cita.doctor_id.active: + raise ValidationError(_( + '%(nombre)s está dado de baja y no se le pueden asignar ' + 'citas. Reactívalo en el módulo de Médicos o elige otro ' + 'especialista.' + ) % {'nombre': cita.doctor_id.name}) + def get_available_slots(self, date, servicio_id, doctor_id=None, duration=None): """Retorna slots disponibles para una fecha y servicio. diff --git a/odoo-addons/skeen_pacientes/models/res_partner.py b/odoo-addons/skeen_pacientes/models/res_partner.py index c7c8f65..07ec158 100644 --- a/odoo-addons/skeen_pacientes/models/res_partner.py +++ b/odoo-addons/skeen_pacientes/models/res_partner.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- -from odoo import models, fields, api +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError class ResPartner(models.Model): @@ -183,6 +184,21 @@ class ResPartner(models.Model): vals['patient_id'] = self.env['ir.sequence'].next_by_code('skeen.patient') or 'PAT-001' return super(ResPartner, self).create(vals_list) + @api.constrains('primary_doctor_id') + def _check_primary_doctor_activo(self): + """Un médico dado de baja no admite pacientes nuevos. + + Solo corre cuando cambia la asignación, así que los pacientes que ya + estaban ligados a ese médico conservan su historial. + """ + for partner in self: + if partner.primary_doctor_id and not partner.primary_doctor_id.active: + raise ValidationError( + '%s está dado de baja y no se le pueden asignar pacientes. ' + 'Reactívalo en Médicos o elige otro especialista.' + % partner.primary_doctor_id.name + ) + def action_view_appointments(self): self.ensure_one() return { diff --git a/odoo-addons/skeen_ventas/models/venta.py b/odoo-addons/skeen_ventas/models/venta.py index 83eeba2..4bb2a44 100644 --- a/odoo-addons/skeen_ventas/models/venta.py +++ b/odoo-addons/skeen_ventas/models/venta.py @@ -360,6 +360,17 @@ class SkeenVentaLine(models.Model): if bool(line.service_id) == bool(line.item_id): raise ValidationError(_('La línea debe tener un servicio O un artículo, no ambos.')) + @api.constrains('prescribed_by_id') + def _check_prescribed_by_activo(self): + """Las ventas no se le asignan a un médico dado de baja (ni a él ni a + sus comisiones). Solo corre al cambiar la asignación.""" + for line in self: + if line.prescribed_by_id and not line.prescribed_by_id.active: + raise ValidationError(_( + '%(nombre)s está dado de baja y no se le pueden asignar ' + 'ventas.' + ) % {'nombre': line.prescribed_by_id.name}) + @api.depends('quantity', 'price_unit') def _compute_subtotal(self): for line in self: diff --git a/odoo-addons/skeen_whatsapp/controllers/frontend.py b/odoo-addons/skeen_whatsapp/controllers/frontend.py index db6d071..fd1ee02 100644 --- a/odoo-addons/skeen_whatsapp/controllers/frontend.py +++ b/odoo-addons/skeen_whatsapp/controllers/frontend.py @@ -1917,6 +1917,9 @@ class SkeenFrontendController(http.Controller): vals['image_1920'] = data['photo'] patient = request.env['res.partner'].sudo().create(vals) return json_response({'status': 'success', 'patient': self._patient_to_dict(patient)}, 201) + except ValidationError as e: + # Reglas de negocio (p. ej. médico dado de baja): mensaje legible + return json_response({'status': 'error', 'message': e.args[0] if e.args else str(e)}, 400) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @@ -1985,6 +1988,8 @@ class SkeenFrontendController(http.Controller): # El rastro principal es la auditoría; el chatter es extra pass return json_response({'status': 'success', 'patient': self._patient_to_dict(patient)}) + except ValidationError as e: + return json_response({'status': 'error', 'message': e.args[0] if e.args else str(e)}, 400) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @@ -3170,7 +3175,12 @@ class SkeenFrontendController(http.Controller): # de dinero por médico son del módulo Médicos (admin/recepción). fu = current_user(request.env) hide_money = bool(fu and fu.role == 'medico') - doctors = request.env['hr.employee'].sudo().search([('active', '=', True)], limit=100) + # Por defecto solo el personal activo: así desaparecen de la agenda, + # del selector de médico de pacientes y del selector de ventas. + # La pantalla de Médicos pide include_inactive=1 para poder reactivar. + domain = [] if str(kw.get('include_inactive')).lower() in ('1', 'true') else [('active', '=', True)] + doctors = request.env['hr.employee'].sudo().with_context(active_test=False).search( + domain, order='active desc, name', limit=300) # Pacientes asignados por médico (primary_doctor_id) counts = {} for g in request.env['res.partner'].sudo().read_group( @@ -3237,6 +3247,7 @@ class SkeenFrontendController(http.Controller): 'doctors': [{ 'id': d.id, 'name': d.name, + 'active': d.active, 'job_title': d.job_title or '', 'work_phone': d.work_phone or '', 'work_email': d.work_email or '', @@ -3285,6 +3296,10 @@ class SkeenFrontendController(http.Controller): if data['staff_type'] not in ('doctor', 'cosmetologa'): return json_response({'status': 'error', 'message': "staff_type debe ser 'doctor' o 'cosmetologa'"}, 400) vals['staff_type'] = data['staff_type'] + if 'active' in data: + # Desactivar oculta al médico de la agenda, de la asignación de + # pacientes y del selector de ventas; no borra su historial. + vals['active'] = bool(data.get('active')) if vals: previos = {k: getattr(doctor, k) for k in vals} doctor.write(vals) @@ -4012,6 +4027,8 @@ class SkeenFrontendController(http.Controller): if data.get('state') == 'confirmed': sale.action_confirm() return json_response({'status': 'success', 'sale': self._sale_to_dict(sale)}, 201) + except ValidationError as e: + return json_response({'status': 'error', 'message': e.args[0] if e.args else str(e)}, 400) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500)