Punto 13: alta/baja de médicos desde el módulo Médicos
- 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.
This commit is contained in:
@@ -133,13 +133,17 @@ const Medicos: FC = () => {
|
|||||||
const [employees, setEmployees] = useState<Doctor[]>([]);
|
const [employees, setEmployees] = useState<Doctor[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(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<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const res = await odooApi.getDoctors();
|
const res = await odooApi.getDoctors({ includeInactive: true });
|
||||||
if (res.status === 'success') setEmployees(res.doctors);
|
if (res.status === 'success') setEmployees(res.doctors);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Error al cargar médicos');
|
setError('Error al cargar médicos');
|
||||||
@@ -153,9 +157,33 @@ const Medicos: FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const doctors = useMemo(() => {
|
const doctors = useMemo(() => {
|
||||||
const medical = employees.filter((d) => isMedicalTitle(d.job_title));
|
const visibles = mostrarBaja ? employees : employees.filter((d) => d.active !== false);
|
||||||
return medical.length > 0 ? medical : employees;
|
const medical = visibles.filter((d) => isMedicalTitle(d.job_title));
|
||||||
}, [employees]);
|
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<Record<number, string>>({});
|
const [pctDraft, setPctDraft] = useState<Record<number, string>>({});
|
||||||
const [savingPct, setSavingPct] = useState<number | null>(null);
|
const [savingPct, setSavingPct] = useState<number | null>(null);
|
||||||
@@ -299,7 +327,19 @@ const Medicos: FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title="Médicos" subtitle="Equipo médico">
|
<Layout title="Médicos" subtitle="Equipo médico">
|
||||||
<PageHeader title="Médicos" subtitle="Especialistas de la clínica" />
|
<PageHeader title="Médicos" subtitle="Especialistas de la clínica">
|
||||||
|
{countBaja > 0 && (
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={mostrarBaja}
|
||||||
|
onChange={(e) => setMostrarBaja(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-theme-border-strong"
|
||||||
|
/>
|
||||||
|
Mostrar dados de baja ({countBaja})
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
@@ -318,6 +358,7 @@ const Medicos: FC = () => {
|
|||||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
|
||||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Puesto</th>
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Puesto</th>
|
||||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Tipo</th>
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Tipo</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase" title="Un médico dado de baja no aparece en la agenda ni recibe pacientes o ventas">Estado</th>
|
||||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Teléfono</th>
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Teléfono</th>
|
||||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden lg:table-cell">Email</th>
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden lg:table-cell">Email</th>
|
||||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">% Comisión</th>
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">% Comisión</th>
|
||||||
@@ -348,6 +389,24 @@ const Medicos: FC = () => {
|
|||||||
className="max-w-[150px]"
|
className="max-w-[150px]"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="p-3" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={d.active !== false}
|
||||||
|
disabled={savingActive === d.id}
|
||||||
|
onClick={() => void toggleActivo(d)}
|
||||||
|
title={d.active === false ? 'Dar de alta (reactivar)' : 'Dar de baja'}
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-1 text-[11px] font-medium transition-colors disabled:opacity-50 ${
|
||||||
|
d.active === false
|
||||||
|
? 'border-rose-300 bg-rose-50 text-rose-700'
|
||||||
|
: 'border-emerald-300 bg-brand-mint text-emerald-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`h-1.5 w-1.5 rounded-full ${d.active === false ? 'bg-rose-500' : 'bg-emerald-600'}`} />
|
||||||
|
{d.active === false ? 'Baja' : 'Activo'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">
|
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">
|
||||||
<a href={`tel:${d.work_phone}`} className="hover:text-theme-heading">
|
<a href={`tel:${d.work_phone}`} className="hover:text-theme-heading">
|
||||||
<Phone size={14} className="inline mr-1 text-theme-muted" />
|
<Phone size={14} className="inline mr-1 text-theme-muted" />
|
||||||
@@ -414,6 +473,20 @@ const Medicos: FC = () => {
|
|||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
rows={[
|
rows={[
|
||||||
|
{
|
||||||
|
label: 'Estado',
|
||||||
|
value: (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void toggleActivo(d)}
|
||||||
|
className={`rounded-full border px-2 py-0.5 text-xs font-medium ${
|
||||||
|
d.active === false ? 'border-rose-300 bg-rose-50 text-rose-700' : 'border-emerald-300 bg-brand-mint text-emerald-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d.active === false ? 'Baja' : 'Activo'}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ label: 'Teléfono', value: d.work_phone || '-' },
|
{ label: 'Teléfono', value: d.work_phone || '-' },
|
||||||
{ label: 'Email', value: d.work_email || '-' },
|
{ label: 'Email', value: d.work_email || '-' },
|
||||||
{ label: '% Comisión', value: `${d.commission_pct ?? 0}%` },
|
{ label: '% Comisión', value: `${d.commission_pct ?? 0}%` },
|
||||||
|
|||||||
@@ -371,6 +371,8 @@ export interface ConfigAuditResponse {
|
|||||||
export interface Doctor {
|
export interface Doctor {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
/** false = dado de baja: no aparece en agenda, pacientes ni ventas */
|
||||||
|
active?: boolean;
|
||||||
job_title: string;
|
job_title: string;
|
||||||
work_phone: string;
|
work_phone: string;
|
||||||
work_email: string;
|
work_email: string;
|
||||||
@@ -1658,12 +1660,14 @@ export const odooApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Médicos
|
// Médicos
|
||||||
async getDoctors(): Promise<{ status: string; doctors: Doctor[] }> {
|
async getDoctors(opts?: { includeInactive?: boolean }): Promise<{ status: string; doctors: Doctor[] }> {
|
||||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/doctors`);
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/doctors`, {
|
||||||
|
params: opts?.includeInactive ? { include_inactive: 1 } : undefined,
|
||||||
|
});
|
||||||
return data;
|
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);
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}`, payload);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -406,6 +406,21 @@ class SkeenCita(models.Model):
|
|||||||
if cita.duration and (cita.duration < 5 or cita.duration > 480):
|
if cita.duration and (cita.duration < 5 or cita.duration > 480):
|
||||||
raise ValidationError(_('La duración debe estar entre 5 y 480 minutos'))
|
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):
|
def get_available_slots(self, date, servicio_id, doctor_id=None, duration=None):
|
||||||
"""Retorna slots disponibles para una fecha y servicio.
|
"""Retorna slots disponibles para una fecha y servicio.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
from odoo import models, fields, api
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
class ResPartner(models.Model):
|
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'
|
vals['patient_id'] = self.env['ir.sequence'].next_by_code('skeen.patient') or 'PAT-001'
|
||||||
return super(ResPartner, self).create(vals_list)
|
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):
|
def action_view_appointments(self):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -360,6 +360,17 @@ class SkeenVentaLine(models.Model):
|
|||||||
if bool(line.service_id) == bool(line.item_id):
|
if bool(line.service_id) == bool(line.item_id):
|
||||||
raise ValidationError(_('La línea debe tener un servicio O un artículo, no ambos.'))
|
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')
|
@api.depends('quantity', 'price_unit')
|
||||||
def _compute_subtotal(self):
|
def _compute_subtotal(self):
|
||||||
for line in self:
|
for line in self:
|
||||||
|
|||||||
@@ -1917,6 +1917,9 @@ class SkeenFrontendController(http.Controller):
|
|||||||
vals['image_1920'] = data['photo']
|
vals['image_1920'] = data['photo']
|
||||||
patient = request.env['res.partner'].sudo().create(vals)
|
patient = request.env['res.partner'].sudo().create(vals)
|
||||||
return json_response({'status': 'success', 'patient': self._patient_to_dict(patient)}, 201)
|
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:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
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
|
# El rastro principal es la auditoría; el chatter es extra
|
||||||
pass
|
pass
|
||||||
return json_response({'status': 'success', 'patient': self._patient_to_dict(patient)})
|
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:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
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).
|
# de dinero por médico son del módulo Médicos (admin/recepción).
|
||||||
fu = current_user(request.env)
|
fu = current_user(request.env)
|
||||||
hide_money = bool(fu and fu.role == 'medico')
|
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)
|
# Pacientes asignados por médico (primary_doctor_id)
|
||||||
counts = {}
|
counts = {}
|
||||||
for g in request.env['res.partner'].sudo().read_group(
|
for g in request.env['res.partner'].sudo().read_group(
|
||||||
@@ -3237,6 +3247,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'doctors': [{
|
'doctors': [{
|
||||||
'id': d.id,
|
'id': d.id,
|
||||||
'name': d.name,
|
'name': d.name,
|
||||||
|
'active': d.active,
|
||||||
'job_title': d.job_title or '',
|
'job_title': d.job_title or '',
|
||||||
'work_phone': d.work_phone or '',
|
'work_phone': d.work_phone or '',
|
||||||
'work_email': d.work_email or '',
|
'work_email': d.work_email or '',
|
||||||
@@ -3285,6 +3296,10 @@ class SkeenFrontendController(http.Controller):
|
|||||||
if data['staff_type'] not in ('doctor', 'cosmetologa'):
|
if data['staff_type'] not in ('doctor', 'cosmetologa'):
|
||||||
return json_response({'status': 'error', 'message': "staff_type debe ser 'doctor' o 'cosmetologa'"}, 400)
|
return json_response({'status': 'error', 'message': "staff_type debe ser 'doctor' o 'cosmetologa'"}, 400)
|
||||||
vals['staff_type'] = data['staff_type']
|
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:
|
if vals:
|
||||||
previos = {k: getattr(doctor, k) for k in vals}
|
previos = {k: getattr(doctor, k) for k in vals}
|
||||||
doctor.write(vals)
|
doctor.write(vals)
|
||||||
@@ -4012,6 +4027,8 @@ class SkeenFrontendController(http.Controller):
|
|||||||
if data.get('state') == 'confirmed':
|
if data.get('state') == 'confirmed':
|
||||||
sale.action_confirm()
|
sale.action_confirm()
|
||||||
return json_response({'status': 'success', 'sale': self._sale_to_dict(sale)}, 201)
|
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:
|
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