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:
Kimi
2026-09-24 16:03:02 +00:00
parent d0eac00a40
commit 09b4f25bb8
6 changed files with 146 additions and 10 deletions

View File

@@ -133,13 +133,17 @@ const Medicos: FC = () => {
const [employees, setEmployees] = useState<Doctor[]>([]);
const [loading, setLoading] = useState(true);
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(() => {
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<Record<number, string>>({});
const [savingPct, setSavingPct] = useState<number | null>(null);
@@ -299,7 +327,19 @@ const Medicos: FC = () => {
return (
<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.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">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" 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 lg:table-cell">Email</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]"
/>
</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">
<a href={`tel:${d.work_phone}`} className="hover:text-theme-heading">
<Phone size={14} className="inline mr-1 text-theme-muted" />
@@ -414,6 +473,20 @@ const Medicos: FC = () => {
</Badge>
}
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: 'Email', value: d.work_email || '-' },
{ label: '% Comisión', value: `${d.commission_pct ?? 0}%` },

View File

@@ -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;
},