diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 92db06b..3b44feb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { AuthProvider, useAuth } from './lib/auth'; import { GlosarioProvider } from './lib/glosario'; import { SucursalesProvider } from './lib/sucursales'; import { ServiceCategoriesProvider } from './lib/service-categories'; +import { RecursosProvider } from './lib/recursos'; import { QuickLinksProvider } from './lib/quicklinks'; import { PatientDrawerProvider } from './components/PatientDrawer'; import ChatNotifications from './lib/chat-notifications'; @@ -142,6 +143,7 @@ const App: FC = () => { + @@ -187,6 +189,7 @@ const App: FC = () => { + diff --git a/frontend/src/components/RecursosPanel.tsx b/frontend/src/components/RecursosPanel.tsx new file mode 100644 index 0000000..fd907d1 --- /dev/null +++ b/frontend/src/components/RecursosPanel.tsx @@ -0,0 +1,423 @@ +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) => ( + + + + + + + + + + ))} + +
NombreTipoCódigoSucursalesCosto/horaEstadoAcciones
+ + {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 ( + + ); + })} +
+
+