import type { FC, ReactNode } from 'react'; import { useCallback, useEffect, useState } from 'react'; import { Plus, Pencil, Power, DoorOpen, Save } from 'lucide-react'; import { Button, Input, Modal, Select, TextArea, EmptyState, Skeleton, toast } from './ui'; import { odooApi, RECURSOS_DEFAULT, RECURSO_TIPOS, type ClinicSettings, type Recurso, type RecursoTipo, type RecursosSettings, } from '../services/odoo'; import { useRecursos } from '../lib/recursos'; import { useSucursales } from '../lib/sucursales'; import { useGlosario } from '../lib/glosario'; interface FormState { name: string; code: string; tipo: RecursoTipo; sequence: string; capacidad: string; costo_hora: string; color: string; notas: string; activo: boolean; sucursal_ids: number[]; } const emptyForm: FormState = { name: '', code: '', tipo: 'cabina', sequence: '10', capacidad: '0', costo_hora: '0', color: '#eda588', notas: '', activo: true, sucursal_ids: [], }; interface Props { settings: ClinicSettings; onSaved: (settings: ClinicSettings) => void; } /** * Configuración › Recursos: catálogo de recursos físicos de la agenda * (cabinas, salas, consultorios, equipos) y sus ajustes. La sección siempre se * puede abrir para configurar; el resto de la agenda solo la usa cuando * `recursos.activo` está encendido. */ const RecursosPanel: FC = ({ settings, onSaved }) => { const { refresh } = useRecursos(); const { sucursales } = useSucursales(); const { term } = useGlosario(); const [recursos, setRecursos] = useState([]); const [loading, setLoading] = useState(true); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [submitting, setSubmitting] = useState(false); const [form, setForm] = useState(emptyForm); const [conf, setConf] = useState(settings.recursos || RECURSOS_DEFAULT); const [savingConf, setSavingConf] = useState(false); useEffect(() => { setConf(settings.recursos || RECURSOS_DEFAULT); }, [settings.recursos]); const load = useCallback(async () => { try { setLoading(true); const res = await odooApi.getRecursos({ includeInactive: true }); if (res.status === 'success') setRecursos(res.recursos); } catch (err) { toast.error('Error al cargar recursos'); console.error(err); } finally { setLoading(false); } }, []); useEffect(() => { load(); }, [load]); const openCreate = () => { setEditing(null); setForm(emptyForm); setModalOpen(true); }; const openEdit = (r: Recurso) => { setEditing(r); setForm({ name: r.name, code: r.code, tipo: r.tipo, sequence: String(r.sequence), capacidad: String(r.capacidad ?? 0), costo_hora: String(r.costo_hora ?? 0), color: r.color || '', notas: r.notas || '', activo: r.activo, sucursal_ids: (r.sucursales || []).map((s) => s.id), }); setModalOpen(true); }; const submit = async () => { if (!form.name.trim()) { toast.error('El nombre es obligatorio'); return; } const payload = { name: form.name.trim(), code: form.code.trim() || undefined, tipo: form.tipo, sequence: parseInt(form.sequence, 10) || 10, capacidad: parseInt(form.capacidad, 10) || 0, costo_hora: parseFloat(form.costo_hora) || 0, color: form.color.trim(), notas: form.notas, activo: form.activo, sucursal_ids: form.sucursal_ids, }; try { setSubmitting(true); if (editing) { await odooApi.updateRecurso(editing.id, payload); toast.success('Recurso actualizado'); } else { await odooApi.createRecurso(payload); toast.success('Recurso creado'); } setModalOpen(false); await load(); refresh(); } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; toast.error(msg || 'No se pudo guardar el recurso'); console.error(err); } finally { setSubmitting(false); } }; const toggleActivo = async (r: Recurso) => { if (r.activo) { if (!window.confirm(`¿Desactivar el recurso "${r.name}"? Las citas ya registradas no se pierden.`)) return; try { const res = await odooApi.deleteRecurso(r.id); toast.success(res.message || 'Recurso desactivado'); await load(); refresh(); } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; toast.error(msg || 'No se pudo desactivar el recurso'); } } else { try { await odooApi.updateRecurso(r.id, { activo: true }); toast.success('Recurso activado'); await load(); refresh(); } catch { toast.error('No se pudo activar el recurso'); } } }; const toggleSucursal = (id: number) => setForm((f) => ({ ...f, sucursal_ids: f.sucursal_ids.includes(id) ? f.sucursal_ids.filter((x) => x !== id) : [...f.sucursal_ids, id], })); const guardarConf = async () => { setSavingConf(true); try { const res = await odooApi.putClinicSettings({ recursos: conf, motivo: 'Recursos de la agenda desde Configuración', }); if (res.status === 'success') { onSaved(res.settings); toast.success('Ajustes de recursos guardados'); } } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; toast.error(msg || 'No se pudieron guardar los ajustes'); } finally { setSavingConf(false); } }; return (

Recursos de la agenda

{term('room_plural')}, consultorios y equipos que se pueden asignar a las citas y bloquear. Mientras esté desactivado, la agenda se ve y funciona igual que siempre.

{/* Ajustes del módulo */}
setConf({ ...conf, activo: v })} ayuda="Al desactivarlo, el selector y los filtros desaparecen de la agenda." /> setConf({ ...conf, validar_solape: v })} ayuda="Impide dos citas o bloqueos en el mismo recurso a la misma hora." /> setConf({ ...conf, obligatorio_al_agendar: v })} ayuda="Exige elegir al menos un recurso al crear una cita." /> setConf({ ...conf, permitir_multiples: v })} ayuda="Una cita puede usar cabina + equipo a la vez." /> setConf({ ...conf, mostrar_en_agenda: v })} ayuda="Muestra el recurso y su color en las tarjetas de cita." /> setConf({ ...conf, filtrar_como_doctores: v })} ayuda="Ofrece el filtro por recurso junto al de doctores." />
{/* Catálogo */}

Catálogo de recursos

Un recurso se desactiva, nunca se borra.

{loading ? ( ) : recursos.length === 0 ? ( } /> ) : (
{recursos.map((r) => ( ))}
Nombre Tipo Código Sucursales Costo/hora Estado Acciones
{r.color && ( )} {r.name} {r.tipo_label || r.tipo} {r.code} {r.sucursales?.length ? r.sucursales.map((s) => s.name).join(', ') : 'Todas'} {r.costo_hora ? `$${r.costo_hora}` : '—'} {r.activo ? 'Activo' : 'Inactivo'}
)} setModalOpen(false)} title={editing ? 'Editar recurso' : 'Nuevo recurso'} maxWidth="lg" footer={ <> } >
setForm({ ...form, name: e.target.value })} /> setForm({ ...form, code: e.target.value })} placeholder="Se genera del nombre si se deja vacío" />
setForm({ ...form, sequence: e.target.value })} />
setForm({ ...form, capacidad: e.target.value })} /> setForm({ ...form, costo_hora: e.target.value })} />
setForm({ ...form, color: e.target.value })} className="h-10 w-12 shrink-0 rounded-lg border border-theme-border-strong bg-theme-surface cursor-pointer" aria-label="Color del recurso" /> setForm({ ...form, color: e.target.value })} placeholder="#eda588" />

Sucursales (vacío = todas)

{sucursales.map((s) => { const active = form.sucursal_ids.includes(s.id); return ( ); })}