fase 3.1: recursos fisicos en agenda (modelo skeen.recurso con semilla cabina/consultoria, asignacion multiple a citas con validacion de solape, bloqueos por recurso, lista de espera desde recursos, vista/filtro por recurso, reporte de ocupacion); apagado por defecto

This commit is contained in:
2026-10-11 11:33:59 +00:00
parent 8ff13b9c2a
commit ee8a844d96
29 changed files with 1986 additions and 78 deletions

View File

@@ -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 = () => {
<GlosarioProvider>
<SucursalesProvider>
<ServiceCategoriesProvider>
<RecursosProvider>
<QuickLinksProvider>
<Router>
<ToastProvider />
@@ -187,6 +189,7 @@ const App: FC = () => {
</PatientDrawerProvider>
</Router>
</QuickLinksProvider>
</RecursosProvider>
</ServiceCategoriesProvider>
</SucursalesProvider>
</GlosarioProvider>

View File

@@ -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<Props> = ({ settings, onSaved }) => {
const { refresh } = useRecursos();
const { sucursales } = useSucursales();
const { term } = useGlosario();
const [recursos, setRecursos] = useState<Recurso[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Recurso | null>(null);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState<FormState>(emptyForm);
const [conf, setConf] = useState<RecursosSettings>(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 (
<div className="space-y-6">
<div className="flex items-start gap-3">
<DoorOpen size={20} className="mt-0.5 shrink-0 text-theme-accent" />
<div>
<h3 className="font-heading text-xl text-theme-heading">Recursos de la agenda</h3>
<p className="text-sm text-theme-muted">
{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.
</p>
</div>
</div>
{/* Ajustes del módulo */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Toggle label="Activar recursos" checked={conf.activo}
onChange={(v) => setConf({ ...conf, activo: v })}
ayuda="Al desactivarlo, el selector y los filtros desaparecen de la agenda." />
<Toggle label="Validar solape" checked={conf.validar_solape}
onChange={(v) => setConf({ ...conf, validar_solape: v })}
ayuda="Impide dos citas o bloqueos en el mismo recurso a la misma hora." />
<Toggle label="Obligatorio al agendar" checked={conf.obligatorio_al_agendar}
onChange={(v) => setConf({ ...conf, obligatorio_al_agendar: v })}
ayuda="Exige elegir al menos un recurso al crear una cita." />
<Toggle label="Permitir varios recursos" checked={conf.permitir_multiples}
onChange={(v) => setConf({ ...conf, permitir_multiples: v })}
ayuda="Una cita puede usar cabina + equipo a la vez." />
<Toggle label="Mostrar en la agenda" checked={conf.mostrar_en_agenda}
onChange={(v) => setConf({ ...conf, mostrar_en_agenda: v })}
ayuda="Muestra el recurso y su color en las tarjetas de cita." />
<Toggle label="Filtrar como los doctores" checked={conf.filtrar_como_doctores}
onChange={(v) => setConf({ ...conf, filtrar_como_doctores: v })}
ayuda="Ofrece el filtro por recurso junto al de doctores." />
</div>
<div className="flex justify-end">
<Button loading={savingConf} onClick={guardarConf}>
<Save size={16} className="mr-2" /> Guardar ajustes
</Button>
</div>
{/* Catálogo */}
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div>
<h4 className="font-heading text-lg text-theme-heading">Catálogo de recursos</h4>
<p className="text-sm text-theme-muted">Un recurso se desactiva, nunca se borra.</p>
</div>
<Button onClick={openCreate} className="sm:ml-auto">
<Plus size={16} className="mr-2" />
Nuevo recurso
</Button>
</div>
{loading ? (
<Skeleton count={3} className="h-12 w-full" />
) : recursos.length === 0 ? (
<EmptyState
title="Sin recursos"
subtitle="Agrega la primera cabina, sala o equipo de la clínica."
actionLabel="Nuevo recurso"
onAction={openCreate}
icon={<DoorOpen size={28} />}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<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">Tipo</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Código</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Sucursales</th>
<th className="text-right p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Costo/hora</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase w-32">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{recursos.map((r) => (
<tr key={r.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm font-medium text-theme-heading">
<span className="inline-flex items-center gap-2">
{r.color && (
<span className="inline-block h-3 w-3 rounded-full border border-theme-border" style={{ backgroundColor: r.color }} />
)}
{r.name}
</span>
</td>
<td className="p-3 text-sm text-theme-muted">{r.tipo_label || r.tipo}</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{r.code}</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">
{r.sucursales?.length ? r.sucursales.map((s) => s.name).join(', ') : 'Todas'}
</td>
<td className="p-3 text-sm text-theme-muted text-right hidden sm:table-cell">
{r.costo_hora ? `$${r.costo_hora}` : '—'}
</td>
<td className="p-3 text-sm">
<span className={r.activo ? 'text-emerald-600 font-medium' : 'text-theme-muted'}>
{r.activo ? 'Activo' : 'Inactivo'}
</span>
</td>
<td className="p-3">
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(r)} title="Editar">
<Pencil size={16} className="text-theme-muted" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => toggleActivo(r)}
title={r.activo ? 'Desactivar' : 'Activar'}
>
<Power size={16} className={r.activo ? 'text-rose-500' : 'text-emerald-600'} />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Editar recurso' : 'Nuevo recurso'}
maxWidth="lg"
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
<Button onClick={submit} loading={submitting}>{editing ? 'Guardar cambios' : 'Crear recurso'}</Button>
</>
}
>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input label="Nombre *" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<Input
label="Código"
value={form.code}
onChange={(e) => setForm({ ...form, code: e.target.value })}
placeholder="Se genera del nombre si se deja vacío"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Select
label="Tipo"
options={RECURSO_TIPOS.map((t) => ({ value: t.value, label: t.label }))}
value={form.tipo}
onChange={(e) => setForm({ ...form, tipo: e.target.value as RecursoTipo })}
/>
<Input
label="Orden"
type="number"
value={form.sequence}
onChange={(e) => setForm({ ...form, sequence: e.target.value })}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Input
label="Capacidad"
type="number"
min={0}
value={form.capacidad}
onChange={(e) => setForm({ ...form, capacidad: e.target.value })}
/>
<Input
label="Costo por hora"
type="number"
min={0}
step="0.01"
value={form.costo_hora}
onChange={(e) => setForm({ ...form, costo_hora: e.target.value })}
/>
<div>
<label className="block text-xs font-medium text-theme-muted mb-1.5">Color (agenda)</label>
<div className="flex items-center gap-2">
<input
type="color"
value={form.color || '#eda588'}
onChange={(e) => 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"
/>
<Input value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} placeholder="#eda588" />
</div>
</div>
</div>
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-1.5">Sucursales (vacío = todas)</p>
<div className="flex flex-wrap gap-1.5">
{sucursales.map((s) => {
const active = form.sucursal_ids.includes(s.id);
return (
<button
key={s.id}
type="button"
onClick={() => toggleSucursal(s.id)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
active
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
{s.name}
</button>
);
})}
</div>
</div>
<TextArea label="Notas" value={form.notas} onChange={(e) => setForm({ ...form, notas: e.target.value })} />
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
<input
type="checkbox"
checked={form.activo}
onChange={(e) => setForm({ ...form, activo: e.target.checked })}
className="rounded border-theme-border-strong"
/>
Recurso activo
</label>
</div>
</Modal>
</div>
);
};
const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: ReactNode }> = ({
label, checked, onChange, ayuda,
}) => (
<label className="flex cursor-pointer items-start gap-3 rounded-2xl border border-theme-border px-4 py-3">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)}
className="mt-0.5 h-4 w-4 accent-theme-accent" />
<span>
<span className="block text-sm font-medium text-theme-heading">{label}</span>
{ayuda && <span className="mt-0.5 block text-xs text-theme-muted">{ayuda}</span>}
</span>
</label>
);
export default RecursosPanel;

View File

@@ -5,6 +5,8 @@ import {
Badge, Button, Input, Modal, TextArea, toast,
} from '../ui';
import { odooApi, type Bloqueo, type BloqueoMotivo, type Doctor } from '../../services/odoo';
import { useRecursos } from '../../lib/recursos';
import { useClinicSettings } from '../../lib/clinic-settings';
const MOTIVOS: { value: BloqueoMotivo; label: string }[] = [
{ value: 'vacaciones', label: 'Vacaciones' },
@@ -63,6 +65,9 @@ const BloqueosModal: FC<Props> = ({
isOpen, onClose, date, doctors, isMedico, ownDoctorId, initial, puedeGestionar = true, onChanged,
}) => {
const [doctorIds, setDoctorIds] = useState<number[]>([]);
const [recursoIds, setRecursoIds] = useState<number[]>([]);
const { recursos } = useRecursos();
const { recursos: recursosConf } = useClinicSettings();
const [dateFrom, setDateFrom] = useState(date);
const [dateTo, setDateTo] = useState(date);
const [repite, setRepite] = useState(false);
@@ -80,6 +85,7 @@ const BloqueosModal: FC<Props> = ({
const resetForm = useCallback((fecha?: string) => {
setEditing(null);
setDoctorIds(isMedico && ownDoctorId ? [ownDoctorId] : []);
setRecursoIds([]);
setDateFrom(fecha || date);
setDateTo(fecha || date);
setRepite(false);
@@ -112,6 +118,7 @@ const BloqueosModal: FC<Props> = ({
if (initial) {
setEditing(null);
setDoctorIds(initial.doctorIds);
setRecursoIds([]);
setDateFrom(date);
setDateTo(date);
setRepite(false);
@@ -135,6 +142,9 @@ const BloqueosModal: FC<Props> = ({
const toggleDoctor = (id: number) => {
setDoctorIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
const toggleRecurso = (id: number) => {
setRecursoIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
const toggleDay = (d: number) => {
setDays((prev) => (prev.includes(d) ? prev.filter((x) => x !== d) : [...prev, d]));
};
@@ -153,8 +163,8 @@ const BloqueosModal: FC<Props> = ({
}, [repite, dateFrom, rangeEnd, days]);
const submit = async (force = false) => {
if (doctorIds.length === 0) {
toast.error('Selecciona al menos un médico');
if (doctorIds.length === 0 && recursoIds.length === 0) {
toast.error('Selecciona al menos un médico o un recurso');
return;
}
if (!dateFrom) {
@@ -174,6 +184,7 @@ const BloqueosModal: FC<Props> = ({
if (editing) {
await odooApi.updateBloqueo(editing.id, {
doctor_id: doctorIds[0],
recurso_ids: recursoIds,
date: dateFrom,
all_day: allDay,
time_from: parseTimeToFloat(timeFrom),
@@ -186,6 +197,7 @@ const BloqueosModal: FC<Props> = ({
} else {
const res = await odooApi.createBloqueo({
doctor_ids: doctorIds,
recurso_ids: recursoIds,
date_from: dateFrom,
date_to: repite ? rangeEnd : dateFrom,
days: repite ? days : [],
@@ -223,7 +235,7 @@ const BloqueosModal: FC<Props> = ({
const remove = async (b: Bloqueo, serie = false) => {
const etiqueta = serie
? `toda la serie (${b.grupo})`
: `${b.doctor} · ${b.date}`;
: `${b.doctor || (b.recursos?.map((r) => r.name).join(', ') || 'bloqueo')} · ${b.date}`;
if (!window.confirm(`¿Eliminar el bloqueo de ${etiqueta}?`)) return;
try {
if (serie && b.grupo) {
@@ -242,7 +254,8 @@ const BloqueosModal: FC<Props> = ({
const startEdit = (b: Bloqueo) => {
setEditing(b);
setDoctorIds([b.doctor_id]);
setDoctorIds(b.doctor_id ? [b.doctor_id] : []);
setRecursoIds(b.recurso_ids || []);
setDateFrom(b.date);
setDateTo(b.date);
setRepite(false);
@@ -297,7 +310,9 @@ const BloqueosModal: FC<Props> = ({
{/* Médicos (multi-selección) */}
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-1.5">Médicos *</p>
<p className="text-xs font-medium text-theme-muted uppercase mb-1.5">
Médicos{recursosConf.activo ? ' y/o recursos' : ' *'}
</p>
{isMedico ? (
<p className="text-sm text-theme-heading">
{doctors.find((d) => d.id === ownDoctorId)?.name || 'Mi agenda'}
@@ -334,6 +349,39 @@ const BloqueosModal: FC<Props> = ({
)}
</div>
{/* Recursos (multi-selección): solo cuando la clínica los tiene activos */}
{recursosConf.activo && (
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-1.5">Recursos</p>
<div className="flex flex-wrap gap-1.5">
{recursos.map((r) => {
const active = recursoIds.includes(r.id);
return (
<button
key={r.id}
type="button"
onClick={() => toggleRecurso(r.id)}
className={`px-2.5 py-1 rounded-full text-xs border transition-all ${
active
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-surface text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
{r.color && (
<span className="inline-block h-2 w-2 rounded-full mr-1 align-middle"
style={{ backgroundColor: r.color }} />
)}
{r.name}
</button>
);
})}
</div>
<p className="mt-1.5 text-xs text-theme-muted">
Bloquear un recurso cierra esa cabina/sala/equipo aunque el médico siga libre.
</p>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Input
label={repite ? 'Desde *' : 'Fecha *'}
@@ -472,7 +520,12 @@ const BloqueosModal: FC<Props> = ({
className="flex items-center justify-between gap-2 p-2 bg-theme-bg rounded-lg text-sm"
>
<span className="min-w-0 text-theme-heading truncate">
<span className="font-medium">{b.doctor}</span>
<span className="font-medium">
{b.doctor || (b.recursos?.length ? b.recursos.map((r) => r.name).join(', ') : 'Bloqueo')}
</span>
{b.doctor && b.recursos?.length ? (
<span className="text-theme-muted"> · {b.recursos.map((r) => r.name).join(', ')}</span>
) : null}
<span className="text-theme-muted">
{' · '}{b.all_day ? 'Todo el día' : `${horaFloatStr(b.time_from)}–${horaFloatStr(b.time_to)}`}
</span>

View File

@@ -1,10 +1,10 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { CalendarPlus, Clock3, MessageCircle, Plus, Repeat, Trash2, Users } from 'lucide-react';
import { Badge, Button, Input, Modal, Select, TextArea, toast } from '../ui';
import PatientCombobox from '../PatientCombobox';
import { odooApi, type Doctor, type ListaEspera, type Patient, type Service } from '../../services/odoo';
import { useGlosario } from '../../lib/glosario';
import { useRecursos } from '../../lib/recursos';
const PRIORIDADES = [
{ value: 'alta', label: 'Alta' },
@@ -22,12 +22,15 @@ export const whatsappLink = (phone: string, text: string) => {
return `https://wa.me/${digits}?text=${encodeURIComponent(text)}`;
};
// Salas donde puede esperar el paciente (toggle del panel + etiqueta de la tarjeta)
export const SALAS: { key: '' | 'consultoria' | 'cabina'; label: string; chip: string }[] = [
{ key: 'consultoria', label: 'Consultoría', chip: 'bg-sky-100 text-sky-800 border-sky-300' },
{ key: 'cabina', label: 'Cabina', chip: 'bg-amber-100 text-amber-800 border-amber-300' },
// Paleta de colores para distinguir las salas/recursos en la lista de espera.
// El nombre de cada sala sale del catálogo de recursos (Configuración › Recursos).
const SALA_CHIPS = [
'bg-sky-100 text-sky-800 border-sky-300',
'bg-amber-100 text-amber-800 border-amber-300',
'bg-emerald-100 text-emerald-800 border-emerald-300',
'bg-violet-100 text-violet-800 border-violet-300',
'bg-rose-100 text-rose-800 border-rose-300',
];
const salaSiguiente = (sala: string) => (sala === 'consultoria' ? 'cabina' : 'consultoria');
interface PanelProps {
entries: ListaEspera[];
@@ -51,11 +54,17 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
entries, loading, loadingAgendaId, onAgendar, onQuitar, onAdd, onRefresh, puedeGestionar = true, onChangeSala,
}) => {
const [abierto, setAbierto] = useState(true);
const { term } = useGlosario();
// Etiqueta de la sala "cabina" toma el término configurable (Cabina/Sala/Consultorio).
const salas = SALAS.map((s) => (s.key === 'cabina' ? { ...s, label: term('room_singular') } : s));
// Filtro por sala: el toggle de arriba decide qué pacientes se muestran.
const [salaFiltro, setSalaFiltro] = useState<'todas' | 'consultoria' | 'cabina'>('todas');
const { recursos } = useRecursos();
// Salas = recursos del catálogo. Filtro por sala: el toggle decide qué se muestra.
const salas = useMemo(
() => recursos.map((r, i) => ({ key: r.code, label: r.name, chip: SALA_CHIPS[i % SALA_CHIPS.length] })),
[recursos],
);
const salaSiguiente = (sala: string) => {
const idx = salas.findIndex((s) => s.key === sala);
return salas[(idx + 1) % salas.length]?.key || salas[0]?.key || '';
};
const [salaFiltro, setSalaFiltro] = useState<string>('todas');
const visibles = salaFiltro === 'todas' ? entries : entries.filter((e) => (e.sala || '') === salaFiltro);
const cuenta = (sala: string) => entries.filter((e) => (e.sala || '') === sala).length;
return (
@@ -80,7 +89,7 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
<button
key={s.key}
type="button"
onClick={() => setSalaFiltro(s.key as 'todas' | 'consultoria' | 'cabina')}
onClick={() => setSalaFiltro(s.key as string)}
className={`px-2.5 py-1 text-[11px] font-medium rounded-full transition ${
salaFiltro === s.key
? 'bg-violet-600 text-white'
@@ -228,7 +237,7 @@ interface ModalProps {
export const ListaEsperaModal: FC<ModalProps> = ({
isOpen, onClose, doctors, services, patient, defaultDoctorId, defaultServiceId, isMedico, onCreated,
}) => {
const { term } = useGlosario();
const { recursos } = useRecursos();
const [patientId, setPatientId] = useState('');
const [patientName, setPatientName] = useState('');
const [doctorId, setDoctorId] = useState(defaultDoctorId || '');
@@ -354,8 +363,7 @@ export const ListaEsperaModal: FC<ModalProps> = ({
label="Sala de espera"
options={[
{ value: '', label: 'Sin asignar' },
{ value: 'consultoria', label: 'Consultoría' },
{ value: 'cabina', label: term('room_singular') },
...recursos.map((r) => ({ value: r.code, label: r.name })),
]}
value={sala}
onChange={(e) => setSala(e.target.value)}

View File

@@ -0,0 +1,167 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { DoorOpen } from 'lucide-react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type RecursosReportRow } from '../../services/odoo';
import { useReportRange } from '../../lib/reportRange';
/** Ocupación por recurso (cabinas, salas, equipos) en el periodo. */
const RecursosReport: FC = () => {
const { start, end, setStart, setEnd } = useReportRange('recursos');
const [rows, setRows] = useState<RecursosReportRow[]>([]);
const [dias, setDias] = useState(0);
const [horasDisponibles, setHorasDisponibles] = useState(0);
const [totales, setTotales] = useState({ citas: 0, horas_ocupadas: 0, ingresos_estimados: 0 });
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getRecursosReport(start, end);
if (res.status === 'success') {
setRows(res.recursos);
setDias(res.dias);
setHorasDisponibles(res.horas_disponibles);
setTotales(res.totales);
}
} catch (err) {
toast.error('Error al cargar la ocupación por recurso');
console.error(err);
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
try {
setExporting(true);
await exportToExcel({
filename: `recursos-${start}_${end}`,
sheetName: 'Recursos',
title: 'Ocupación por recurso',
subtitle: `Del ${start} al ${end} · ${dias} día(s) · ${horasDisponibles} h disponibles`,
columns: [
{ header: 'Recurso', key: 'recurso' },
{ header: 'Tipo', key: 'tipo' },
{ header: 'Citas', key: 'citas', format: 'number' },
{ header: 'Horas ocupadas', key: 'horas', format: 'number' },
{ header: 'Ocupación (%)', key: 'ocupacion', format: 'number' },
{ header: 'Ingresos estimados', key: 'ingresos', format: 'currency' },
],
rows: rows.map((r) => ({
recurso: r.recurso,
tipo: r.tipo_label || r.tipo,
citas: r.citas,
horas: r.horas_ocupadas,
ocupacion: r.ocupacion_pct,
ingresos: r.ingresos_estimados,
})),
totals: { recurso: 'Total', citas: totales.citas, horas: totales.horas_ocupadas, ingresos: totales.ingresos_estimados },
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
const chartData = rows.map((r) => ({ name: r.recurso, horas: r.horas_ocupadas, citas: r.citas }));
return (
<div className="space-y-4 sm:space-y-6">
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-end gap-3">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<ExportButton onClick={exportar} loading={exporting} />
<p className="text-sm text-theme-muted sm:ml-auto">
{totales.citas} citas · {totales.horas_ocupadas} h ocupadas de {horasDisponibles} h
</p>
</div>
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4">Horas ocupadas por recurso</h3>
{loading ? (
<Skeleton className="h-[260px] w-full" />
) : rows.length === 0 ? (
<EmptyState title="Sin recursos" subtitle="Agrega recursos en Configuración › Recursos." icon={<DoorOpen size={28} />} />
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={chartData} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e7e5e4" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} allowDecimals={false} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} />
<Bar dataKey="horas" fill="#57534e" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</Card.Body>
</Card>
{!loading && rows.length > 0 && (
<Card>
<Card.Body>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Recurso</th>
<th className="text-left px-4 py-3">Tipo</th>
<th className="text-right px-4 py-3">Citas</th>
<th className="text-right px-4 py-3">Horas ocupadas</th>
<th className="text-right px-4 py-3">Ocupación</th>
<th className="text-right px-4 py-3">Costo/hora</th>
<th className="text-right px-4 py-3">Ingresos estimados</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{rows.map((r) => (
<tr key={r.recurso_id} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">
<span className="inline-flex items-center gap-2">
{r.color && <span className="inline-block h-3 w-3 rounded-full" style={{ backgroundColor: r.color }} />}
{r.recurso}
</span>
</td>
<td className="px-4 py-3 text-theme-muted">{r.tipo_label || r.tipo}</td>
<td className="px-4 py-3 text-right text-theme-muted">{r.citas}</td>
<td className="px-4 py-3 text-right text-theme-muted">{r.horas_ocupadas} h</td>
<td className="px-4 py-3 text-right text-theme-heading">{r.ocupacion_pct}%</td>
<td className="px-4 py-3 text-right text-theme-muted">{r.costo_hora ? `$${r.costo_hora}` : '—'}</td>
<td className="px-4 py-3 text-right font-semibold text-theme-heading">
${r.ingresos_estimados.toLocaleString('es-MX')}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card.Body>
</Card>
)}
</div>
);
};
export default RecursosReport;

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT } from '../services/odoo';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, RECURSOS_DEFAULT } from '../services/odoo';
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
@@ -11,6 +11,7 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
impuestos: { ...IMPUESTOS_DEFAULT, ...(settings?.impuestos || {}) },
notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(settings?.notificaciones || {}) },
membresias: { ...MEMBRESIAS_DEFAULT, ...(settings?.membresias || {}) },
recursos: { ...RECURSOS_DEFAULT, ...(settings?.recursos || {}) },
});
let actual: ClinicSettings = normalizar(null);

View File

@@ -28,6 +28,7 @@ export const MENU_ITEMS: MenuItem[] = [
{ label: 'Comisiones', href: '/reportes?tab=comisiones' },
{ label: 'Adeudos', href: '/reportes?tab=adeudos' },
{ label: 'Horas agenda', href: '/reportes?tab=horas-agenda' },
{ label: 'Recursos', href: '/reportes?tab=recursos', termKey: 'room_plural' },
{ label: 'Paquetes', href: '/reportes?tab=paquetes', termKey: 'package_plural' },
{ label: 'Membresías', href: '/membresias' },
{ label: 'Vendedores', href: '/reportes?tab=vendedores' },

View File

@@ -6,6 +6,7 @@
export const PERM_KEYS: string[] = [
'agenda.ver', 'agenda.agendar', 'agenda.editar', 'agenda.eliminar',
'agenda.mover', 'agenda.visitas', 'agenda.todas', 'agenda.bloquear',
'recursos.ver', 'recursos.editar',
'pacientes.ver', 'pacientes.expediente', 'pacientes.crear', 'pacientes.editar',
'pacientes.eliminar', 'pacientes.exportar', 'pacientes.saldos',
'visitas.ver', 'visitas.crear', 'visitas.editar', 'visitas.eliminar', 'visitas.archivos',
@@ -34,6 +35,7 @@ const FORM_WRITE = ['admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'];
const READ_PERM_KEYS = new Set([
'agenda.ver', 'agenda.todas',
'recursos.ver',
'pacientes.ver', 'pacientes.expediente', 'pacientes.exportar', 'pacientes.saldos',
'visitas.ver',
'inventario.ver',
@@ -49,6 +51,7 @@ const READ_PERM_KEYS = new Set([
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'agenda.ver': ALL, 'agenda.agendar': W, 'agenda.editar': W, 'agenda.eliminar': W,
'agenda.mover': W, 'agenda.visitas': W, 'agenda.todas': W, 'agenda.bloquear': W,
'recursos.ver': ALL, 'recursos.editar': AG,
'pacientes.ver': ALL, 'pacientes.expediente': ALL, 'pacientes.crear': W,
'pacientes.editar': W, 'pacientes.eliminar': W, 'pacientes.exportar': ALL,
'pacientes.saldos': ALL,

View File

@@ -6,9 +6,13 @@ import { odooApi, type BookingConfig } from '../services/odoo';
// responden 404 cuando el módulo está desactivado.
/**
* Config del portal público. Devuelve `config=null` cuando está desactivado
* (404) o el backend no responde: en ese caso el portal se muestra como "no
* Config del portal público. Devuelve `config=null` cuando está desactivado o
* el backend no responde: en ese caso el portal se muestra como "no
* disponible" y no se ofrece ningún enlace.
*
* Con las reservas en línea apagadas el endpoint responde `activo:false` (200);
* si una versión anterior responde 404, se captura en silencio y se trata como
* "no configurado" (nunca se registra como error en consola).
*/
export const useBookingConfig = () => {
const [config, setConfig] = useState<BookingConfig | null>(null);

View File

@@ -0,0 +1,81 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { FC, ReactNode } from 'react';
import { odooApi, type Recurso } from '../services/odoo';
import { useAuth } from './auth';
// Respaldo histórico: los dos valores que estaban hardcodeados en la lista de
// espera antes de que existieran los recursos. Si /recursos falla, la lista de
// espera sigue mostrando "Cabina"/"Consultoría" igual que antes.
export const RECURSOS_FALLBACK: Recurso[] = [
{ id: -1, code: 'cabina', name: 'Cabina', tipo: 'cabina', tipo_label: 'Cabina', activo: true, sequence: 10, sucursales: [], capacidad: 0, costo_hora: 0, color: '', notas: '' },
{ id: -2, code: 'consultoria', name: 'Consultoría', tipo: 'consultorio', tipo_label: 'Consultorio', activo: true, sequence: 20, sucursales: [], capacidad: 0, costo_hora: 0, color: '', notas: '' },
];
const NOMBRES_HISTORICOS: Record<string, string> = { cabina: 'Cabina', consultoria: 'Consultoría' };
interface RecursosContextValue {
/** Recursos activos, ordenados por sequence. */
recursos: Recurso[];
loading: boolean;
/** Nombre del recurso por código; cae al histórico y luego al propio código. */
labelFor: (code?: string | null) => string;
refresh: () => void;
}
const RecursosContext = createContext<RecursosContextValue | undefined>(undefined);
export const RecursosProvider: FC<{ children: ReactNode }> = ({ children }) => {
const { token } = useAuth();
const [recursos, setRecursos] = useState<Recurso[]>(RECURSOS_FALLBACK);
const [loading, setLoading] = useState(false);
const [attempt, setAttempt] = useState(0);
const refresh = useCallback(() => setAttempt((n) => n + 1), []);
useEffect(() => {
if (!token) return;
let alive = true;
setLoading(true);
odooApi.getRecursos()
.then((res) => {
if (!alive || res.status !== 'success' || !res.recursos?.length) return;
setRecursos(res.recursos);
})
.catch(() => {
// fallback: RECURSOS_FALLBACK
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [token, attempt]);
const porCodigo = useMemo(() => {
const m = new Map<string, Recurso>();
for (const r of recursos) m.set(r.code, r);
return m;
}, [recursos]);
const labelFor = useCallback(
(code?: string | null) => {
if (!code) return '';
return porCodigo.get(code)?.name || NOMBRES_HISTORICOS[code] || code;
},
[porCodigo],
);
const value = useMemo(
() => ({ recursos, loading, labelFor, refresh }),
[recursos, loading, labelFor, refresh],
);
return <RecursosContext.Provider value={value}>{children}</RecursosContext.Provider>;
};
export const useRecursos = (): RecursosContextValue => {
const ctx = useContext(RecursosContext);
if (!ctx) throw new Error('useRecursos debe usarse dentro de RecursosProvider');
return ctx;
};

View File

@@ -1,6 +1,6 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall, Wallet, ChevronDown, CalendarOff, Users, Play, Check, Globe, Bell, FileSignature } from 'lucide-react';
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall, Wallet, ChevronDown, CalendarOff, Users, Play, Check, Globe, Bell, FileSignature, DoorOpen } from 'lucide-react';
import Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer';
import AgendaPrint from '../components/AgendaPrint';
@@ -32,6 +32,7 @@ import { useAuth } from '../lib/auth';
import { useGlosario } from '../lib/glosario';
import { useClinicSettings } from '../lib/clinic-settings';
import { useSucursales } from '../lib/sucursales';
import { useRecursos } from '../lib/recursos';
import { todayLocal } from '../lib/utils';
import { useAutoRefresh, AutoRefreshBadge } from '../lib/autoRefresh';
@@ -88,10 +89,11 @@ const stateOptions = [
// ---- Preferencias persistidas de la agenda (vista y filtros, NO la fecha) ----
const AGENDA_PREFS_KEY = 'skeen_agenda_prefs';
type AgendaViewMode = 'lista' | 'doctores' | 'horas' | 'semana';
type AgendaViewMode = 'lista' | 'doctores' | 'horas' | 'semana' | 'recursos';
interface AgendaPrefs {
viewMode?: AgendaViewMode;
doctorFilter?: string;
recursoFilter?: string;
staffTypeFilter?: '' | 'doctor' | 'cosmetologa';
}
const readAgendaPrefs = (): AgendaPrefs => {
@@ -103,7 +105,7 @@ const readAgendaPrefs = (): AgendaPrefs => {
}
};
const VALID_VIEW_MODES: AgendaViewMode[] = ['lista', 'doctores', 'horas', 'semana'];
const VALID_VIEW_MODES: AgendaViewMode[] = ['lista', 'doctores', 'horas', 'semana', 'recursos'];
// ---- Conflictos de agenda (solape / bloqueo) en lenguaje humano ----
export interface AgendaConflict {
@@ -161,7 +163,12 @@ const Agenda: FC = () => {
const { user, canAgenda, can } = useAuth();
const { term } = useGlosario();
const { sucursales, defaultCode } = useSucursales();
const { recursos } = useRecursos();
const clinicSettings = useClinicSettings();
const recursosConf = clinicSettings.recursos;
const recursosActivos = recursosConf.activo;
// Término del glosario para una "sala"/"cabina"/"consultorio" (módulo recursos).
const recursoTerm = term('room_singular').toLowerCase();
const { openPatient } = usePatientDrawer();
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor).
// Cosmetología ve la agenda completa (es personal clínico, sin la privacidad
@@ -205,6 +212,10 @@ const Agenda: FC = () => {
const saved = readAgendaPrefs().staffTypeFilter;
return saved === 'doctor' || saved === 'cosmetologa' ? saved : '';
});
const [recursoFilter, setRecursoFilter] = useState(() => {
const saved = readAgendaPrefs().recursoFilter;
return typeof saved === 'string' ? saved : '';
});
const [viewMode, setViewMode] = useState<AgendaViewMode>(() => {
const saved = readAgendaPrefs().viewMode;
return saved && VALID_VIEW_MODES.includes(saved) ? saved : 'doctores';
@@ -216,11 +227,12 @@ const Agenda: FC = () => {
viewMode,
staffTypeFilter,
doctorFilter: isMedico ? undefined : doctorFilter,
recursoFilter,
} as AgendaPrefs));
} catch {
// localStorage no disponible: ignorar
}
}, [viewMode, staffTypeFilter, doctorFilter, isMedico]);
}, [viewMode, staffTypeFilter, doctorFilter, isMedico, recursoFilter]);
const [quickFilter, setQuickFilter] = useState<'' | 'libres' | 'primera' | 'checkin' | 'noshow' | 'porconfirmar' | 'cancelled' | 'waitlist'>('');
// Confirmación telefónica en lote (solo vista Lista)
const [selectedIds, setSelectedIds] = useState<number[]>([]);
@@ -277,6 +289,7 @@ const Agenda: FC = () => {
sessions: 1,
duration: '',
branch: '',
recurso_ids: [] as number[],
});
const quickToggleClass = editingId
? 'hidden'
@@ -316,6 +329,7 @@ const Agenda: FC = () => {
const filteredAppointments = appointments.filter((apt) => {
if (stateFilter && apt.state !== stateFilter) return false;
if (doctorFilter && String(apt.doctor_id) !== doctorFilter) return false;
if (recursoFilter && !(apt.recurso_ids || []).includes(Number(recursoFilter))) return false;
return true;
});
@@ -420,6 +434,23 @@ const Agenda: FC = () => {
<Globe size={size} className="inline shrink-0 text-sky-600" aria-label="Reservada en línea por el cliente" />
) : null;
// Distintivo de recursos asignados (color por recurso) en la tarjeta de la cita.
const recursoDots = (apt: Appointment) => {
if (!recursosActivos || !recursosConf.mostrar_en_agenda || !apt.recursos?.length) return null;
return (
<span className="ml-1 inline-flex items-center gap-0.5 align-middle">
{apt.recursos.map((r) => (
<span
key={r.id}
title={r.name}
className="inline-block h-2 w-2 rounded-full border border-white/70"
style={{ backgroundColor: r.color || '#c7bcdd' }}
/>
))}
</span>
);
};
const confirmTitle = (apt: Appointment): string => {
if (['arrived', 'in_progress', 'done'].includes(apt.state)) return '2da confirmación: llegó a la clínica';
if (apt.phone_confirmed) {
@@ -519,6 +550,31 @@ const Agenda: FC = () => {
const gridDoctorCount = (doctorId: number | null) =>
gridAppointments.filter((a) => (doctorId ? a.doctor_id === doctorId : !a.doctor_id)).length;
// ---- Vista por recurso (análoga a la vista por médico) ----
// Un recurso puede tener varias citas simultáneas si el médico es distinto,
// pero la validación de solape del backend lo impide en el mismo recurso.
const gridRecursos = recursoFilter
? recursos.filter((r) => String(r.id) === recursoFilter)
: recursos;
const recursoCellEntries = (recursoId: number, slot: number) => {
const i = slotIndex(slot);
const out: { apt: Appointment; isStart: boolean }[] = [];
for (const a of gridAppointments) {
if (!(a.recurso_ids || []).includes(recursoId)) continue;
const start = aptSlot(a.time);
if (start === null) continue;
const s = slotIndex(start);
const span = aptSlotSpan(a);
if (i >= s && i < s + span) out.push({ apt: a, isStart: i === s });
}
return out;
};
const recursoCount = (recursoId: number) =>
gridAppointments.filter((a) => (a.recurso_ids || []).includes(recursoId)).length;
const bloqueoRecursoEnCelda = (recursoId: number, slot: number) =>
bloqueos.find((b) => (b.recurso_ids || []).includes(recursoId) &&
(b.all_day || (slot < b.time_to && slot + SLOT_MIN / 60 > b.time_from)));
// Bloqueo que cubre una celda (médico × bloque de 15 min)
const bloqueoEnCelda = (doctorId: number | null, slot: number) =>
doctorId
@@ -1455,11 +1511,24 @@ const Agenda: FC = () => {
}
};
const toggleFormRecurso = (id: number) => {
setForm((f) => {
if (f.recurso_ids.includes(id)) {
return { ...f, recurso_ids: f.recurso_ids.filter((x) => x !== id) };
}
return { ...f, recurso_ids: recursosConf.permitir_multiples ? [...f.recurso_ids, id] : [id] };
});
};
const createAppointment = async () => {
if (!form.patient_id || !form.service_id || !form.date || !form.time) {
toast.error('Completa los campos obligatorios');
return;
}
if (recursosActivos && recursosConf.obligatorio_al_agendar && form.recurso_ids.length === 0) {
toast.error(`Selecciona al menos un ${recursoTerm}`);
return;
}
const service = services.find((s) => String(s.id) === form.service_id);
const sessions = form.sessions > 1 ? form.sessions : ((service?.package_sessions ?? 0) > 1 ? (service?.package_sessions ?? 1) : 1);
try {
@@ -1474,6 +1543,7 @@ const Agenda: FC = () => {
sessions,
duration: form.duration ? parseInt(form.duration, 10) : 0,
branch: form.branch || defaultCode || undefined,
...(recursosActivos ? { recurso_ids: form.recurso_ids } : {}),
} as never);
toast.success(sessions > 1 ? `Paquete creado: ${sessions} citas` : 'Cita creada');
// Si la cita salió de la lista de espera, se marca como agendada
@@ -1489,6 +1559,7 @@ const Agenda: FC = () => {
sessions: 1,
duration: '',
branch: '',
recurso_ids: [],
});
await load();
} catch (err) {
@@ -1516,6 +1587,7 @@ const Agenda: FC = () => {
sessions: 1,
duration: String(apt.duration || apt.service_duration || DEFAULT_DURATION),
branch: apt.branch || '',
recurso_ids: apt.recurso_ids || [],
});
setCreateOpen(true);
};
@@ -1526,6 +1598,10 @@ const Agenda: FC = () => {
toast.error('Completa los campos obligatorios');
return;
}
if (recursosActivos && recursosConf.obligatorio_al_agendar && form.recurso_ids.length === 0) {
toast.error(`Selecciona al menos un ${recursoTerm}`);
return;
}
try {
setSubmitting(true);
await odooApi.updateAppointment(editingId, {
@@ -1537,6 +1613,7 @@ const Agenda: FC = () => {
notes: form.notes,
duration: form.duration ? parseInt(form.duration, 10) : 0,
branch: form.branch || defaultCode || undefined,
...(recursosActivos ? { recurso_ids: form.recurso_ids } : {}),
} as never);
toast.success('Cita actualizada');
setCreateOpen(false);
@@ -1556,7 +1633,7 @@ const Agenda: FC = () => {
setQuickMode(false);
setEditingId(null);
setPickedPatient(null);
setForm((f) => ({ ...f, patient_id: '', duration: '', branch: '', ...(medicoEmployeeId ? { doctor_id: String(medicoEmployeeId) } : {}) }));
setForm((f) => ({ ...f, patient_id: '', duration: '', branch: '', recurso_ids: [], ...(medicoEmployeeId ? { doctor_id: String(medicoEmployeeId) } : {}) }));
setQuickForm({
name: '',
phone: '',
@@ -1584,7 +1661,7 @@ const Agenda: FC = () => {
? String(medicoEmployeeId)
: (opts?.doctorId != null ? String(opts.doctorId) : doctorFilter);
const t = opts?.time ?? '';
setForm((f) => ({ ...f, patient_id: '', date: d, time: t, doctor_id: docId, branch: '' }));
setForm((f) => ({ ...f, patient_id: '', date: d, time: t, doctor_id: docId, branch: '', recurso_ids: [] }));
setQuickForm({
name: '',
phone: '',
@@ -1931,6 +2008,17 @@ const Agenda: FC = () => {
<CalendarRange size={16} className="mr-1" />
Semana
</Button>
{recursosActivos && (
<Button
variant={viewMode === 'recursos' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('recursos')}
title="Recursos en columnas, horas en filas"
>
<DoorOpen size={16} className="mr-1" />
Por recurso
</Button>
)}
</div>
</div>
@@ -2014,6 +2102,51 @@ const Agenda: FC = () => {
)}
</div>
{/* Filtro de recursos: pill por recurso (solo con el módulo activo) */}
{recursosActivos && recursosConf.filtrar_como_doctores && (
<div className="mb-4 sm:mb-5">
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Recurso</p>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setRecursoFilter('')}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
!recursoFilter
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
Todos
</button>
{recursos.map((r) => {
const active = recursoFilter === String(r.id);
return (
<button
key={r.id}
type="button"
onClick={() => setRecursoFilter(active ? '' : String(r.id))}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
active
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
{r.color && <span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: r.color }} />}
{r.name}
<span
className={`inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] ${
active ? 'bg-theme-surface/30 text-theme-inverse' : 'bg-theme-surface text-theme-heading border border-theme-border'
}`}
>
{recursoCount(r.id)}
</span>
</button>
);
})}
</div>
</div>
)}
{/* Filtros rápidos */}
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
{(viewMode === 'doctores' || viewMode === 'horas') && (
@@ -2285,6 +2418,7 @@ const Agenda: FC = () => {
</div>
<div className="truncate opacity-75">
{apt.service}
{recursoDots(apt)}
{(apt.duration || DEFAULT_DURATION) > DEFAULT_DURATION && (
<span className="ml-1 opacity-90">· {apt.duration} min</span>
)}
@@ -2551,6 +2685,118 @@ const Agenda: FC = () => {
{gridLegend}
</div>
)
) : viewMode === 'recursos' ? (
gridRecursos.length === 0 ? (
<EmptyState
title="Sin recursos"
subtitle="Agrega recursos en Configuración › Recursos para ver esta vista."
/>
) : (
<div>
<div className="overflow-auto max-h-[70vh] rounded-2xl border border-theme-border shadow-card">
<table className="w-full border-collapse bg-theme-surface">
<thead>
<tr>
<th className="sticky left-0 top-0 z-30 w-20 bg-theme-surface p-3 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
Hora
</th>
{gridRecursos.map((r) => (
<th key={r.id} className="sticky top-0 z-20 min-w-[180px] bg-theme-surface p-3 border-b border-l border-theme-border">
<div className="flex items-center gap-2.5">
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-theme-heading"
style={{ backgroundColor: r.color || 'var(--color-lilac)' }}
>
<DoorOpen size={15} />
</span>
<div className="min-w-0 text-left">
<p className="truncate text-sm font-semibold text-theme-heading normal-case">
{r.name}
</p>
<p className="truncate text-[11px] font-normal text-theme-muted normal-case">
{r.tipo_label || r.tipo} · {recursoCount(r.id)} {recursoCount(r.id) === 1 ? 'cita' : 'citas'}
</p>
</div>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{gridSlots.map((slot) => {
const isNowSlot = nowSlot !== null && slot === nowSlot;
return (
<tr key={slot} data-grid-slot={slot.toFixed(2)}>
<td className={`sticky left-0 z-10 bg-theme-surface px-2 py-1 text-[11px] font-semibold align-top whitespace-nowrap border-b border-theme-border ${isNowSlot ? 'text-theme-accent border-t-2 border-t-theme-accent' : 'text-theme-muted'}`}>
{horaFloatStr(slot)}
{isNowSlot && (
<span className="ml-1 rounded bg-theme-accent px-1 text-[9px] font-medium text-theme-inverse">ahora</span>
)}
</td>
{gridRecursos.map((r) => {
const cell = recursoCellEntries(r.id, slot);
const bloqueo = bloqueoRecursoEnCelda(r.id, slot);
return (
<td key={r.id} className="p-1 align-top border-b border-l border-theme-border transition-colors">
{bloqueo ? (
<div
className="rounded-lg border border-theme-border py-1.5 text-center text-[10px] font-medium text-theme-muted"
style={{ backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(0,0,0,0.05) 6px, rgba(0,0,0,0.05) 12px)' }}
title={`Bloqueado — ${bloqueo.motivo || 'sin motivo'}`}
>
<Ban size={11} className="inline mr-1" />
Bloqueado
</div>
) : cell.length === 0 ? (
<div
onClick={(e) => clickCeldaLibre(e, date, slot, null)}
className="cursor-pointer rounded-lg border border-dashed border-theme-border-strong py-1.5 text-center text-[10px] font-medium text-theme-muted transition-colors hover:bg-theme-accent-bg"
title={`Nueva cita aquí — ${horaFloatStr(slot)}`}
>
Libre
</div>
) : (
cell.map(({ apt, isStart }) => (
isStart ? (
<div
key={apt.id}
onClick={() => { setTooltip(null); openAptDetail(apt); }}
onMouseEnter={(e) => showAptTooltip(e, apt)}
onMouseLeave={() => setTooltip(null)}
className={`mb-1 cursor-pointer rounded-lg border-l-4 px-2 py-1 text-[11px] leading-tight shadow-card transition-shadow hover:shadow-soft ${aptCategoryBar(apt)} ${gridStateClass[apt.state] || 'bg-theme-bg text-theme-heading'}`}
>
<div className="font-semibold truncate">
<span title={confirmTitle(apt)} className="mr-1 align-middle">{confirmIndicator(apt)}</span>
{portalIcon(apt) && <span title="Reservada en línea" className="mr-0.5 align-middle">{portalIcon(apt)}</span>}
{apt.time} · {apt.patient}
</div>
<div className="truncate opacity-75">
{apt.service}
{apt.doctor ? ` · ${apt.doctor}` : ''}
</div>
</div>
) : (
<div
key={`${apt.id}-cont`}
onClick={() => { setTooltip(null); openAptDetail(apt); }}
title={`${apt.time} · ${apt.patient} (${apt.duration} min)`}
className={`mb-1 h-5 rounded-lg border-l-4 px-2 opacity-80 ${aptCategoryBar(apt)} ${gridStateClass[apt.state] || 'bg-theme-bg'}`}
/>
)
))
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
{gridLegend}
</div>
)
) : visibleAppointments.length === 0 ? (
<EmptyState
title="Sin citas"
@@ -2893,6 +3139,48 @@ const Agenda: FC = () => {
onChange={(e) => setForm({ ...form, doctor_id: e.target.value })}
disabled={isMedico}
/>
{recursosActivos && (
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-1.5">
{term('room_plural')}{recursosConf.obligatorio_al_agendar ? ' *' : ''}
{!recursosConf.permitir_multiples ? ' (uno)' : ''}
</p>
<div className="flex flex-wrap gap-1.5">
{recursos.map((r) => {
const active = form.recurso_ids.includes(r.id);
return (
<button
key={r.id}
type="button"
onClick={() => toggleFormRecurso(r.id)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs border transition-all ${
active
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-surface text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
{r.color && <span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: r.color }} />}
{r.name}
</button>
);
})}
{form.recurso_ids.length > 0 && (
<button
type="button"
onClick={() => setForm((f) => ({ ...f, recurso_ids: [] }))}
className="text-xs text-theme-muted underline underline-offset-2"
>
Quitar
</button>
)}
</div>
{recursosConf.obligatorio_al_agendar && form.recurso_ids.length === 0 && (
<p className="mt-1 text-[11px] text-amber-700">
Esta clínica exige elegir al menos un {recursoTerm} al agendar.
</p>
)}
</div>
)}
<Select
label={term('branch_singular')}
options={sucursales.map((s) => ({ value: s.code, label: s.name }))}
@@ -3099,6 +3387,22 @@ const Agenda: FC = () => {
<p className="text-xs text-theme-muted mb-1">Médico</p>
<p className="text-sm font-medium text-theme-heading">{detailApt.doctor || 'Sin asignar'}</p>
</div>
{recursosActivos && detailApt.recursos && detailApt.recursos.length > 0 && (
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">{term('room_plural')}</p>
<div className="flex flex-wrap gap-1.5">
{detailApt.recursos.map((r) => (
<span
key={r.id}
className="inline-flex items-center gap-1.5 rounded-full border border-theme-border px-2 py-0.5 text-xs font-medium text-theme-heading"
>
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: r.color || '#c7bcdd' }} />
{r.name}
</span>
))}
</div>
</div>
)}
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Duración</p>
<p className="text-sm font-medium text-theme-heading">{detailApt.duration ? `${detailApt.duration} min` : '-'}</p>

View File

@@ -12,6 +12,7 @@ import TicketDesigner from '../components/TicketDesigner';
import ImpactoModal from '../components/ImpactoModal';
import SucursalesPanel from '../components/SucursalesPanel';
import CategoriasPanel from '../components/CategoriasPanel';
import RecursosPanel from '../components/RecursosPanel';
import TerminologiaPanel from '../components/TerminologiaPanel';
import ReservasOnlinePanel from '../components/ReservasOnlinePanel';
import NotificacionesPanel from '../components/NotificacionesPanel';
@@ -31,6 +32,7 @@ const TABS = [
{ key: 'formularios', label: 'Formularios' },
{ key: 'sucursales', label: 'Sucursales' },
{ key: 'categorias', label: 'Categorías' },
{ key: 'recursos', label: 'Recursos' },
{ key: 'membresias', label: 'Membresías' },
{ key: 'terminologia', label: 'Terminología' },
{ key: 'usuarios', label: 'Usuarios' },
@@ -42,7 +44,7 @@ const TABS = [
] as const;
// Secciones que ve gerencia (admin del sistema ve todas).
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'membresias', 'terminologia', 'usuarios', 'roles', 'tickets'];
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'terminologia', 'usuarios', 'roles', 'tickets'];
const SUBCATALOGOS = [
{ key: 'diagnosticos', label: 'Diagnósticos' },
@@ -67,6 +69,7 @@ const Configuracion: FC = () => {
notif_enviadas: 'notificaciones.ver',
formularios: 'formularios.ver',
membresias: 'membresias.editar',
recursos: 'recursos.editar',
};
const tabsVisibles = useMemo(
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key)))
@@ -382,6 +385,12 @@ const Configuracion: FC = () => {
<CategoriasPanel />
</Card.Body>
</Card>
) : tab === 'recursos' ? (
<Card>
<Card.Body>
<RecursosPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'membresias' ? (
<Card>
<Card.Body>

View File

@@ -34,6 +34,7 @@ import VendedoresReport from '../components/reportes/VendedoresReport';
import ConcentradoReport from '../components/reportes/ConcentradoReport';
import RecomendacionesReport from '../components/reportes/RecomendacionesReport';
import KpisReport from '../components/reportes/KpisReport';
import RecursosReport from '../components/reportes/RecursosReport';
import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '../components/ui';
import { odooApi } from '../services/odoo';
import type { CommissionRow } from '../services/odoo';
@@ -101,6 +102,7 @@ const FAMILIAS: ReportFamily[] = [
icon: Activity,
tabs: [
{ key: 'horas-agenda', label: 'Horas agenda', desc: 'Citas atendidas por hora y médico' },
{ key: 'recursos', label: 'Recursos', desc: 'Ocupación de cabinas, salas y equipos' },
{ key: 'inventario', label: 'Inventario', desc: 'Existencias, mínimos y caducidades' },
{ key: 'pagos-servicios', label: 'Pagos · Servicios', desc: 'Cobros agrupados por servicio' },
{ key: 'concentrado', label: 'Concentrado', desc: 'Todo el periodo en una tabla' },
@@ -148,7 +150,7 @@ interface CashReport {
const ReportesContent: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const { hasRole, canSee, can, brand } = useAuth();
const { membresias: membresiasCfg } = useClinicSettings();
const { membresias: membresiasCfg, recursos: recursosCfg } = useClinicSettings();
const esAdmin = hasRole('admin');
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
// por pestana y, cuando no existe la del tab, canSee cae al prefijo
@@ -157,8 +159,9 @@ const ReportesContent: FC = () => {
// La pestaña Membresías además requiere que la clínica tenga el módulo activo.
const puedeTab = useCallback((k: string) => {
if (k === 'membresias' && !membresiasCfg.activo) return false;
if (k === 'recursos' && !recursosCfg.activo) return false;
return canSee(`/reportes?tab=${k}`);
}, [canSee, membresiasCfg.activo]);
}, [canSee, membresiasCfg.activo, recursosCfg.activo]);
const familiasVisibles = useMemo(
() =>
FAMILIAS
@@ -478,6 +481,8 @@ const ReportesContent: FC = () => {
<ExportarReport />
) : tab === 'horas-agenda' ? (
<HorasAgendaReport />
) : tab === 'recursos' ? (
<RecursosReport />
) : tab === 'paquetes' ? (
<PaquetesReport />
) : tab === 'membresias' ? (

View File

@@ -70,6 +70,9 @@ export interface Appointment {
is_first_visit?: boolean;
doctor_id?: number | null;
doctor?: string;
/** Recursos asignados (cabina, sala, equipo). Vacío si el módulo está apagado. */
recurso_ids?: number[];
recursos?: RecursoBrief[];
branch?: string;
medium?: string;
/** Origen de la reserva: 'internal' (default) o 'public_portal' (portal en línea) */
@@ -94,10 +97,60 @@ export interface Appointment {
/** Motivos predefinidos de un bloqueo de agenda */
export type BloqueoMotivo = 'vacaciones' | 'junta' | 'comida' | 'personal' | 'otro';
// ---- Recursos de la agenda (cabinas, salas, consultorios, equipos) ----
export interface RecursoBrief {
id: number;
name: string;
code: string;
color: string;
}
export interface Recurso {
id: number;
code: string;
name: string;
tipo: RecursoTipo;
tipo_label: string;
activo: boolean;
sequence: number;
sucursales: { id: number; code: string; name: string }[];
capacidad: number;
costo_hora: number;
color: string;
notas: string;
}
export type RecursoTipo = 'cabina' | 'sala' | 'consultorio' | 'equipo' | 'otro';
export interface RecursoInput {
name: string;
code?: string;
tipo?: RecursoTipo;
activo?: boolean;
sequence?: number;
sucursal_ids?: number[];
capacidad?: number;
costo_hora?: number;
color?: string;
notas?: string;
motivo?: string;
}
export const RECURSO_TIPOS: { value: RecursoTipo; label: string }[] = [
{ value: 'cabina', label: 'Cabina' },
{ value: 'sala', label: 'Sala' },
{ value: 'consultorio', label: 'Consultorio' },
{ value: 'equipo', label: 'Equipo' },
{ value: 'otro', label: 'Otro' },
];
export interface Bloqueo {
id: number;
doctor_id: number;
doctor: string;
doctor_id: number | null;
doctor: string | null;
/** Recursos bloqueados (cabina, sala, equipo). Vacío si solo bloquea médico. */
recurso_ids?: number[];
recursos?: RecursoBrief[];
date: string;
all_day: boolean;
time_from: number;
@@ -124,8 +177,8 @@ export interface ListaEspera {
priority: 'alta' | 'normal' | 'baja';
priority_label: string;
franja: '' | 'manana' | 'tarde';
// Dónde espera: consultoría (médico) o cabina (tratamiento)
sala?: '' | 'consultoria' | 'cabina';
// Dónde espera: el código de un recurso de la agenda (cabina, sala...)
sala?: string;
sala_label?: string;
date_from: string | null;
date_to: string | null;
@@ -1015,6 +1068,7 @@ export interface ClinicSettings {
reservas_online: ReservasOnlineSettings;
notificaciones: NotificacionesSettings;
membresias: MembresiasSettings;
recursos: RecursosSettings;
}
// ---- Reservas en línea (portal público de auto-agendado) ----
@@ -1049,6 +1103,46 @@ export const RESERVAS_ONLINE_DEFAULT: ReservasOnlineSettings = {
};
// ---- Notificaciones y recordatorios de citas ----
// ---- Recursos físicos de la agenda ----
export interface RecursosSettings {
/** Enciende el módulo de recursos. Apagado = la agenda se ve igual que hoy. */
activo: boolean;
/** Impide dos citas/bloqueos en el mismo recurso a la misma hora. */
validar_solape: boolean;
/** Exige elegir al menos un recurso al crear la cita. */
obligatorio_al_agendar: boolean;
/** Permite asignar más de un recurso a una cita. */
permitir_multiples: boolean;
/** Muestra el recurso (chip/color) en la agenda. */
mostrar_en_agenda: boolean;
/** Ofrece el filtro por recurso junto al de doctores. */
filtrar_como_doctores: boolean;
}
export const RECURSOS_DEFAULT: RecursosSettings = {
activo: false,
validar_solape: true,
obligatorio_al_agendar: false,
permitir_multiples: true,
mostrar_en_agenda: true,
filtrar_como_doctores: true,
};
export interface RecursosReportRow {
recurso_id: number;
recurso: string;
code: string;
tipo: RecursoTipo;
tipo_label: string;
color: string;
citas: number;
horas_ocupadas: number;
horas_disponibles: number;
ocupacion_pct: number;
costo_hora: number;
ingresos_estimados: number;
}
export type NotificacionCanal = 'whatsapp' | 'email';
export type NotificacionCanalConfig = NotificacionCanal | 'ambos';
export type NotificacionTipo = 'recordatorio' | 'confirmacion' | 'cumpleanos' | 'post_visita' | 'recuperacion' | 'manual';
@@ -1541,6 +1635,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
reservas_online: RESERVAS_ONLINE_DEFAULT,
notificaciones: NOTIFICACIONES_DEFAULT,
membresias: MEMBRESIAS_DEFAULT,
recursos: RECURSOS_DEFAULT,
};
export interface FrontendUser {
@@ -2190,6 +2285,40 @@ export const odooApi = {
return data;
},
// ---- Recursos de la agenda (cabinas, salas, consultorios, equipos) ----
async getRecursos(opts?: { includeInactive?: boolean }): Promise<{ status: string; recursos: Recurso[]; total: number; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/recursos`, {
params: opts?.includeInactive ? { include_inactive: 1 } : undefined,
});
return data;
},
async createRecurso(payload: RecursoInput): Promise<{ status: string; recurso: Recurso; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/recursos`, payload);
return data;
},
async updateRecurso(id: number, payload: Partial<RecursoInput>): Promise<{ status: string; recurso: Recurso; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/recursos/${id}`, payload);
return data;
},
async deleteRecurso(id: number): Promise<{ status: string; recurso: Recurso; en_uso: number; message: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/recursos/${id}`);
return data;
},
async getRecursosReport(start: string, end: string): Promise<{
status: string; start: string; end: string; dias: number; horas_disponibles: number;
recursos: RecursosReportRow[]; totales: { citas: number; horas_ocupadas: number; ingresos_estimados: number };
message?: string;
}> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/recursos`, {
params: { start, end },
});
return data;
},
// ---- Ajustes de la clínica ----
async getClinicSettings(): Promise<{ status: string; settings: ClinicSettings; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/clinic-settings`);
@@ -2492,7 +2621,7 @@ export const odooApi = {
return data;
},
async createQuickAppointment(payload: { name: string; phone: string; date: string; time: number; doctor_id?: number; notes?: string; duration?: number; branch?: string }): Promise<{ status: string; appointment: Appointment; patient: Patient; patient_created: boolean; message?: string }> {
async createQuickAppointment(payload: { name: string; phone: string; date: string; time: number; doctor_id?: number; notes?: string; duration?: number; branch?: string; recurso_ids?: number[] }): Promise<{ status: string; appointment: Appointment; patient: Patient; patient_created: boolean; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/quick`, payload);
return data;
},
@@ -2596,6 +2725,7 @@ export const odooApi = {
async createBloqueo(payload: {
doctor_ids: number[];
recurso_ids?: number[];
date_from: string;
date_to?: string;
days?: number[];
@@ -2611,7 +2741,7 @@ export const odooApi = {
},
async updateBloqueo(id: number, payload: Partial<{
doctor_id: number; date: string; all_day: boolean; time_from: number; time_to: number;
doctor_id: number; recurso_ids: number[]; date: string; all_day: boolean; time_from: number; time_to: number;
motivo_tipo: BloqueoMotivo; motivo: string; force: boolean;
}>): Promise<{ status: string; bloqueo: Bloqueo }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/bloqueos/${id}`, payload);