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);

View File

@@ -20,6 +20,7 @@
'views/cita_views.xml',
'views/bloqueo_views.xml',
'views/lista_espera_views.xml',
'views/recurso_views.xml',
'views/menu_views.xml',
],
'installable': True,

View File

@@ -606,6 +606,66 @@ def get_membresias(env):
"""Bloque de membresías vigente (copia). Fuente única para modelos y API."""
return normalizar_membresias(get_settings(env).get('membresias'))
# ---------------------------------------------------------------------------
# Recursos físicos de la agenda (cabinas, salas, consultorios, equipos).
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la agenda — no aparece la
# sección en Configuración ni el selector en el modal de cita, la agenda se ve
# igual que hoy y no corre ninguna validación de solape de recursos. Por eso el
# default es apagado.
#
# activo: enciende todo el módulo de recursos
# validar_solape: impide dos citas/bloqueos en el mismo recurso a la
# misma hora (solo aplica si activo)
# obligatorio_al_agendar: exige elegir al menos un recurso al crear la cita
# permitir_multiples: permite asignar más de un recurso a una cita
# mostrar_en_agenda: muestra el recurso (chips/color) en las tarjetas
# filtrar_como_doctores: ofrece el filtro por recurso junto al de doctores
# ---------------------------------------------------------------------------
RECURSOS_DEFAULT = {
'activo': False,
'validar_solape': True,
'obligatorio_al_agendar': False,
'permitir_multiples': True,
'mostrar_en_agenda': True,
'filtrar_como_doctores': True,
}
RECURSOS_BOOLEANOS = tuple(RECURSOS_DEFAULT)
def normalizar_recursos(raw):
"""Bloque `recursos` completo, con defaults. Tolerante: nunca lanza."""
out = dict(RECURSOS_DEFAULT)
if not isinstance(raw, dict):
return out
for k in RECURSOS_BOOLEANOS:
out[k] = _bool(raw.get(k), RECURSOS_DEFAULT[k])
return out
def validar_recursos(raw):
"""Valida el bloque `recursos` de un PUT. Lanza ValueError si algo no
cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas
para que el merge anidado no reinicie lo que el PUT no tocó."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de recursos debe ser un objeto')
out = {}
for k in RECURSOS_BOOLEANOS:
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
return out
def get_recursos(env):
"""Bloque de recursos vigente (copia). Fuente única para modelos y API."""
return normalizar_recursos(get_settings(env).get('recursos'))
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0
@@ -649,6 +709,7 @@ def get_settings(env):
'reservas_online': dict(RESERVAS_ONLINE_DEFAULT),
'notificaciones': normalizar_notificaciones(None),
'membresias': normalizar_membresias(None),
'recursos': dict(RECURSOS_DEFAULT),
}
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw:
@@ -664,6 +725,7 @@ def get_settings(env):
out['reservas_online'] = normalizar_reservas_online(guardado.get('reservas_online'))
out['notificaciones'] = normalizar_notificaciones(guardado.get('notificaciones'))
out['membresias'] = normalizar_membresias(guardado.get('membresias'))
out['recursos'] = normalizar_recursos(guardado.get('recursos'))
return out
@@ -692,6 +754,10 @@ def set_settings(env, vals):
bloque = validar_membresias(vals['membresias'])
if bloque is not None:
actual['membresias'] = {**actual['membresias'], **bloque}
if 'recursos' in vals:
bloque = validar_recursos(vals['recursos'])
if bloque is not None:
actual['recursos'] = {**actual['recursos'], **bloque}
env['ir.config_parameter'].sudo().set_param(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env)

View File

@@ -45,5 +45,24 @@
<field name="sequence">40</field>
<field name="is_system" eval="True"/>
</record>
<!-- Recursos de la agenda (cabinas, salas, consultorios, equipos).
Los códigos 'cabina'/'consultoria' conservan los valores que antes
estaban hardcodeados en la lista de espera, para que los registros
históricos sigan mostrándose igual. -->
<record id="recurso_cabina" model="skeen.recurso">
<field name="name">Cabina</field>
<field name="code">cabina</field>
<field name="tipo">cabina</field>
<field name="sequence">10</field>
<field name="activo" eval="True"/>
</record>
<record id="recurso_consultoria" model="skeen.recurso">
<field name="name">Consultoría</field>
<field name="code">consultoria</field>
<field name="tipo">consultorio</field>
<field name="sequence">20</field>
<field name="activo" eval="True"/>
</record>
</data>
</odoo>

View File

@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
from . import recurso
from . import cita
from . import bloqueo
from . import lista_espera

View File

@@ -3,6 +3,8 @@
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
from ..clinic_settings import get_recursos
# Motivos predefinidos (el legacy usaba 7 = Vacaciones y 8 = Horario de comida)
MOTIVO_SELECTION = [
('vacaciones', 'Vacaciones'),
@@ -14,17 +16,24 @@ MOTIVO_SELECTION = [
class SkeenCitaBloqueo(models.Model):
"""Bloqueo de agenda de un médico para una fecha concreta.
"""Bloqueo de agenda: de un médico y/o de un recurso físico, en una fecha.
Un registro = un médico × una fecha. Los bloqueos creados juntos (varios
médicos y/o un rango de fechas con repetición semanal) comparten el campo
`grupo`, lo que permite editarlos o eliminarlos como serie.
Un registro = una fecha, con un médico (opcional) y/o recursos (opcional).
Los bloqueos creados juntos (varios médicos/recursos y/o un rango de fechas
con repetición semanal) comparten el campo `grupo`, lo que permite editarlos
o eliminarlos como serie.
`doctor_id` dejó de ser obligatorio para poder bloquear una cabina, sala o
equipo sin médico (módulo de recursos). Debe haber al menos uno de los dos.
"""
_name = 'skeen.cita.bloqueo'
_description = 'Bloqueo de Agenda por Médico'
_description = 'Bloqueo de Agenda'
_order = 'date desc, time_from'
doctor_id = fields.Many2one('hr.employee', string='Médico', required=True)
doctor_id = fields.Many2one('hr.employee', string='Médico')
recurso_ids = fields.Many2many(
'skeen.recurso', 'skeen_bloqueo_recurso_rel', 'bloqueo_id', 'recurso_id',
string='Recursos')
date = fields.Date(string='Fecha', required=True, index=True)
all_day = fields.Boolean(string='Todo el día', default=False)
time_from = fields.Float(string='Desde', default=9.0)
@@ -32,7 +41,7 @@ class SkeenCitaBloqueo(models.Model):
motivo_tipo = fields.Selection(MOTIVO_SELECTION, string='Motivo', default='otro')
motivo = fields.Char(string='Detalle del motivo')
grupo = fields.Char(string='Grupo', index=True, copy=False,
help='Identificador de la serie (médicos y fechas creados juntos).')
help='Identificador de la serie (médicos/recursos y fechas creados juntos).')
created_by = fields.Char(string='Creado por', readonly=True, copy=False)
@api.constrains('time_from', 'time_to', 'all_day')
@@ -44,9 +53,18 @@ class SkeenCitaBloqueo(models.Model):
if b.time_from < 0 or b.time_to > 24:
raise ValidationError(_('Las horas deben estar dentro del día'))
@api.constrains('doctor_id', 'recurso_ids')
def _check_destino(self):
"""Un bloqueo se hace para un médico y/o un recurso: sin destino no
tiene sentido (y no bloquearía nada)."""
for b in self:
if not b.doctor_id and not b.recurso_ids:
raise ValidationError(_(
'Selecciona al menos un médico o un recurso para bloquear.'))
@api.constrains('doctor_id', 'date', 'all_day', 'time_from', 'time_to')
def _check_sin_citas(self):
"""No se puede bloquear un rango que ya tiene citas activas.
"""No se puede bloquear un rango que ya tiene citas activas del médico.
Evita "cerrar la agenda" dejando citas dentro del bloqueo. Al editar un
bloqueo se ignora a sí mismo (no tiene citas asociadas, pero el dominio
@@ -74,6 +92,56 @@ class SkeenCitaBloqueo(models.Model):
'Reprograma o cancela esas citas antes de bloquear.'
) % {'n': len(citas), 'doc': b.doctor_id.name})
@api.constrains('recurso_ids', 'date', 'all_day', 'time_from', 'time_to')
def _check_recursos(self):
"""Un recurso no se puede bloquear si tiene citas activas dentro, ni
solaparse con otro bloqueo del mismo recurso.
Sólo corre con `recursos.activo` (default apagado → sin cambios). El
solape con otro bloqueo respeta además `validar_solape`.
"""
if self.env.context.get('skeen_skip_recursos_solape'):
return
cfg = get_recursos(self.env)
if not cfg['activo']:
return
Citas = self.env['skeen.cita']
for b in self:
if not b.date or not b.recurso_ids:
continue
if b.all_day:
ini_c, fin_c = 0.0, 24.0
else:
ini_c, fin_c = b.time_from, b.time_to
for recurso in b.recurso_ids:
citas = Citas.search([
('date', '=', b.date),
('state', 'not in', ('cancelled', 'no_show', 'waitlist')),
('recurso_ids', 'in', recurso.id),
]).filtered(
lambda c: b.all_day or (
c.time < fin_c and c.time + c._duracion() / 60.0 > ini_c)
)
if citas:
raise ValidationError(_(
'%(rec)s tiene %(n)s cita(s) en ese horario. Reprograma o '
'cancela esas citas antes de bloquear.'
) % {'rec': recurso.name, 'n': len(citas)})
if not cfg['validar_solape']:
continue
otros = self.search([
('id', '!=', b.id),
('date', '=', b.date),
('recurso_ids', 'in', recurso.id),
]).filtered(
lambda o: (o.all_day or b.all_day)
or (ini_c < o.time_to and fin_c > o.time_from)
)
if otros:
raise ValidationError(_(
'%(rec)s ya tiene un bloqueo en ese horario (%(motivo)s).'
) % {'rec': recurso.name, 'motivo': otros[0].nombre_motivo()})
def nombre_motivo(self):
"""Texto legible del motivo para la UI y los mensajes de validación."""
self.ensure_one()

View File

@@ -4,7 +4,7 @@ from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
from datetime import datetime, timedelta
from ..clinic_settings import agenda_bounds
from ..clinic_settings import agenda_bounds, get_recursos
# Estados que liberan el horario: no ocupan agenda ni bloquean solapes.
@@ -134,6 +134,11 @@ class SkeenCita(models.Model):
string='Sucursal',
default=lambda self: self.env['skeen.sucursal'].default_code())
doctor_id = fields.Many2one('hr.employee', string='Médico / Especialista')
# Recursos físicos asignados (cabina, sala, equipo). Vacío mientras la
# configuración de la clínica no active el módulo de recursos.
recurso_ids = fields.Many2many(
'skeen.recurso', 'skeen_cita_recurso_rel', 'cita_id', 'recurso_id',
string='Recursos')
medium = fields.Selection([
('videocall', 'Videollamada'),
('onsite', 'Presencial'),
@@ -432,6 +437,52 @@ class SkeenCita(models.Model):
if b.all_day or (ini < b.time_to and fin > b.time_from):
raise ValidationError(_('El médico tiene un bloqueo (%s)') % b.nombre_motivo())
@api.constrains('date', 'time', 'servicio_id', 'duration', 'recurso_ids', 'state')
def _check_recursos_solape(self):
"""Rechaza citas que pisen un recurso ya ocupado.
Solo corre cuando la clínica tiene `recursos.activo` y `validar_solape`;
con el módulo apagado (default) no cambia nada. Un recurso está ocupado
por otra cita activa (excluye cancelled/no_show/waitlist) o por un
bloqueo de agenda del recurso. El mensaje indica el recurso y con quién
choca, para que la UI lo muestre legible.
"""
if self.env.context.get('skeen_skip_recursos_solape'):
return
cfg = get_recursos(self.env)
if not (cfg['activo'] and cfg['validar_solape']):
return
Bloqueo = self.env['skeen.cita.bloqueo']
for cita in self:
if cita.state in INACTIVE_STATES or not cita.date or not cita.recurso_ids:
continue
ini = cita.time
fin = ini + cita._duracion() / 60.0
for recurso in cita.recurso_ids:
otra = self.search([
('id', '!=', cita.id),
('date', '=', cita.date),
('state', 'not in', INACTIVE_STATES),
('recurso_ids', 'in', recurso.id),
('datetime_start', '<', cita.datetime_end),
('datetime_end', '>', cita.datetime_start),
], limit=1)
if otra:
raise ValidationError(_(
'%(rec)s está ocupado por %(pac)s a las %(hora)s. '
'Elige otro horario o desasigna el recurso.'
) % {'rec': recurso.name, 'pac': otra.partner_id.name or 'otra cita',
'hora': otra.time_str or ''})
bloqueos = Bloqueo.search([
('date', '=', cita.date),
('recurso_ids', 'in', recurso.id),
])
for b in bloqueos:
if b.all_day or (ini < b.time_to and fin > b.time_from):
raise ValidationError(_(
'%(rec)s tiene la agenda bloqueada (%(motivo)s).'
) % {'rec': recurso.name, 'motivo': b.nombre_motivo()})
@api.constrains('date', 'time', 'doctor_id', 'servicio_id', 'duration')
def _check_horario_medico(self):
"""Rechaza citas fuera del horario semanal del médico.

View File

@@ -45,13 +45,13 @@ class SkeenListaEspera(models.Model):
('tarde', 'Tarde'),
], string='Preferencia', default='')
# Dónde espera el paciente: consultoría (consulta con el médico) o cabina
# (tratamiento/valoración). Recepción lo mueve con un click en la etiqueta.
sala = fields.Selection([
('', 'Sin asignar'),
('consultoria', 'Consultoría'),
('cabina', 'Cabina'),
], string='Sala de espera', default='', index=True)
# Dónde espera el paciente: sale del catálogo de recursos (cabina, sala,
# consultorio...) para que el white-label pueda renombrarlos. Los códigos
# semilla 'cabina'/'consultoria' conservan el comportamiento y los datos
# históricos; sin recursos configurados cae a los valores de siempre.
sala = fields.Selection(
selection='_get_sala_selection',
string='Sala de espera', default='', index=True)
date_from = fields.Date(string='Desde')
date_to = fields.Date(string='Hasta')
@@ -88,6 +88,19 @@ class SkeenListaEspera(models.Model):
) % {'nombre': rec.partner_id.name,
'doc': rec.doctor_id.name or 'sin médico asignado'})
@api.model
def _get_sala_selection(self):
"""Opciones de sala: los recursos activos (o los valores de siempre si
aún no hay ninguno). El '' vacío = sin asignar se agrega en la UI."""
return self.env['skeen.recurso']._seleccion_activos()
@api.model
def sala_label(self, value):
"""Nombre legible de una sala ('' o desconocida → '')."""
if not value:
return ''
return dict(self._get_sala_selection()).get(value, value)
@api.depends('priority')
def _compute_priority_rank(self):
for rec in self:

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
# Tipos de recurso físico de la agenda. Los códigos son estables: la lógica
# interna solo usa `code`/`tipo` como etiqueta, nunca para decidir solapes.
RECURSO_TIPOS = [
('cabina', 'Cabina'),
('sala', 'Sala'),
('consultorio', 'Consultorio'),
('equipo', 'Equipo'),
('otro', 'Otro'),
]
class SkeenRecurso(models.Model):
"""Recurso físico que se puede asignar a las citas (cabina, sala, equipo).
Sustituye a las dos opciones que estaban hardcodeadas en la lista de
espera ('cabina'/'consultoria'): los recursos semilla conservan esos
códigos para que los registros históricos sigan mostrándose igual.
`sucursales` vacío = el recurso aplica a todas las sucursales.
Todo el módulo queda apagado con `recursos.activo=False` (por defecto):
mientras no se active, este catálogo no cambia nada en la agenda.
"""
_name = 'skeen.recurso'
_description = 'Recurso de la agenda (cabina, sala, consultorio, equipo)'
_order = 'sequence, name'
name = fields.Char(string='Nombre', required=True, translate=True)
code = fields.Char(string='Código', required=True, copy=False,
help='Identificador estable que se guarda en citas y bloqueos.')
tipo = fields.Selection(RECURSO_TIPOS, string='Tipo', default='cabina', required=True)
activo = fields.Boolean(string='Activo', default=True)
sequence = fields.Integer(string='Secuencia', default=10)
sucursales = fields.Many2many(
'skeen.sucursal', 'skeen_recurso_sucursal_rel', 'recurso_id', 'sucursal_id',
string='Sucursales',
help='Sucursales donde está disponible. Vacío = todas.')
capacidad = fields.Integer(string='Capacidad', default=0)
costo_hora = fields.Float(string='Costo por hora', default=0.0,
help='Costo operativo del recurso por hora, para estimar ingresos en reportes.')
color = fields.Char(string='Color', help='Color hex para distinguirlo en la agenda (p. ej. #1abc9c).')
notas = fields.Text(string='Notas')
cita_ids = fields.Many2many('skeen.cita', string='Citas')
_sql_constraints = [
('unique_code', 'unique(code)', 'El código del recurso debe ser único!')
]
@api.model
def _seleccion_activos(self):
"""[(code, name)] de los recursos activos. Fallback a los valores de
siempre cuando todavía no hay recursos (instancia sin migrar)."""
recs = self.sudo().search([('activo', '=', True)], order='sequence, name')
seleccion = [(r.code, r.name) for r in recs if r.code]
return seleccion or [('consultoria', 'Consultoría'), ('cabina', 'Cabina')]

View File

@@ -7,3 +7,4 @@ access_skeen_cita_state_log_user,Acceso a Bitácora de Estados SKEEN,model_skeen
access_skeen_lista_espera_user,Acceso a Lista de Espera SKEEN,model_skeen_lista_espera,base.group_user,1,1,1,1
access_skeen_sucursal_user,Acceso a Sucursales SKEEN,model_skeen_sucursal,base.group_user,1,1,1,1
access_skeen_service_category_user,Acceso a Categorias de Servicio SKEEN,model_skeen_service_category,base.group_user,1,1,1,1
access_skeen_recurso_user,Acceso a Recursos SKEEN,model_skeen_recurso,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
7 access_skeen_lista_espera_user Acceso a Lista de Espera SKEEN model_skeen_lista_espera base.group_user 1 1 1 1
8 access_skeen_sucursal_user Acceso a Sucursales SKEEN model_skeen_sucursal base.group_user 1 1 1 1
9 access_skeen_service_category_user Acceso a Categorias de Servicio SKEEN model_skeen_service_category base.group_user 1 1 1 1
10 access_skeen_recurso_user Acceso a Recursos SKEEN model_skeen_recurso base.group_user 1 1 1 1

View File

@@ -8,6 +8,7 @@
<tree>
<field name="date"/>
<field name="doctor_id"/>
<field name="recurso_ids" widget="many2many_tags"/>
<field name="all_day"/>
<field name="time_from" widget="float_time"/>
<field name="time_to" widget="float_time"/>
@@ -29,6 +30,8 @@
<group>
<group>
<field name="doctor_id"/>
<field name="recurso_ids" widget="many2many_tags"
options="{'no_create': True}"/>
<field name="date"/>
<field name="motivo_tipo"/>
<field name="motivo"/>

View File

@@ -46,6 +46,8 @@
<field name="servicio_id"/>
<field name="servicio_price" readonly="1"/>
<field name="servicio_duration" readonly="1"/>
<field name="recurso_ids" widget="many2many_tags"
options="{'no_create': True}"/>
<field name="duration"/>
<field name="date"/>
<field name="time" widget="float_time"/>

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Vista de lista de recursos -->
<record id="view_skeen_recurso_tree" model="ir.ui.view">
<field name="name">skeen.recurso.tree</field>
<field name="model">skeen.recurso</field>
<field name="arch" type="xml">
<tree decoration-muted="not activo">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="tipo"/>
<field name="capacidad"/>
<field name="costo_hora"/>
<field name="activo"/>
</tree>
</field>
</record>
<!-- Vista de formulario de recursos -->
<record id="view_skeen_recurso_form" model="ir.ui.view">
<field name="name">skeen.recurso.form</field>
<field name="model">skeen.recurso</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="name"/>
<field name="code"/>
<field name="tipo"/>
<field name="activo"/>
</group>
<group>
<field name="sequence"/>
<field name="capacidad"/>
<field name="costo_hora"/>
<field name="color" placeholder="#1abc9c"/>
</group>
</group>
<group string="Sucursales">
<field name="sucursales" widget="many2many_tags"
options="{'no_create': True}"/>
</group>
<group string="Notas">
<field name="notas" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- Acción de recursos -->
<record id="action_skeen_recurso_tree" model="ir.actions.act_window">
<field name="name">Recursos</field>
<field name="res_model">skeen.recurso</field>
<field name="view_mode">tree,form</field>
</record>
<!-- Menú Recursos (bajo el menú Citas) -->
<menuitem id="menu_skeen_citas_recursos" name="Recursos" parent="menu_skeen_citas"
action="action_skeen_recurso_tree" sequence="40"/>
</odoo>

View File

@@ -20,6 +20,7 @@ from odoo.addons.skeen_citas.clinic_settings import (
get_settings as get_clinic_settings,
set_settings as set_clinic_settings,
get_impuestos,
get_recursos,
)
from odoo.addons.skeen_citas.glosario import (
GLOSARIO_KEYS as GLOSARIO_KEY_SET,
@@ -930,6 +931,19 @@ class SkeenFrontendController(http.Controller):
'state': data.get('state', 'pending'),
'duration': int(data.get('duration') or 0),
}
# Recursos físicos (cabina, sala, equipo). Solo se atienden cuando la
# clínica tiene el módulo activo; con recursos.activo=False se ignora.
cfg_recursos = get_recursos(request.env)
recurso_ids = self._recursos_payload(data) if cfg_recursos['activo'] else []
if cfg_recursos['activo']:
if not cfg_recursos['permitir_multiples'] and len(recurso_ids) > 1:
return json_response({'status': 'error',
'message': 'Solo se permite un recurso por cita'}, 400)
if cfg_recursos['obligatorio_al_agendar'] and not recurso_ids:
return json_response({
'status': 'error',
'message': 'Selecciona al menos un recurso para agendar la cita'}, 400)
base_vals['recurso_ids'] = [(6, 0, recurso_ids)]
first_date = data.get('date')
first_time = data.get('time')
if first_time is not None and not self._valid_agenda_time(first_time):
@@ -1047,6 +1061,7 @@ class SkeenFrontendController(http.Controller):
'notes': data.get('notes', ''),
'state': data.get('state', 'pending'),
'duration': int(data.get('duration') or 0),
**self._recurso_vals_cita(data),
})
return json_response({
'status': 'success',
@@ -1092,6 +1107,17 @@ class SkeenFrontendController(http.Controller):
data = _parse_json_body()
allowed = ['partner_id', 'servicio_id', 'date', 'time', 'doctor_id', 'branch', 'medium', 'notes', 'state', 'duration']
vals = {k: data[k] for k in allowed if k in data}
cfg_recursos = get_recursos(request.env)
if cfg_recursos['activo'] and 'recurso_ids' in data:
recurso_ids = self._recursos_payload(data)
if not cfg_recursos['permitir_multiples'] and len(recurso_ids) > 1:
return json_response({'status': 'error',
'message': 'Solo se permite un recurso por cita'}, 400)
if cfg_recursos['obligatorio_al_agendar'] and not recurso_ids:
return json_response({
'status': 'error',
'message': 'Selecciona al menos un recurso para agendar la cita'}, 400)
vals['recurso_ids'] = [(6, 0, recurso_ids)]
if 'time' in vals and not self._valid_agenda_time(vals['time']):
return json_response(
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (%s)' % self._agenda_horario_txt()}, 400)
@@ -1420,8 +1446,11 @@ class SkeenFrontendController(http.Controller):
def _bloqueo_to_dict(self, b):
return {
'id': b.id,
'doctor_id': b.doctor_id.id,
'doctor': b.doctor_id.name,
'doctor_id': b.doctor_id.id if b.doctor_id else None,
'doctor': b.doctor_id.name if b.doctor_id else None,
'recurso_ids': b.recurso_ids.ids,
'recursos': [{'id': r.id, 'name': r.name, 'code': r.code,
'color': r.color or ''} for r in b.recurso_ids],
'date': b.date.strftime('%Y-%m-%d') if b.date else None,
'all_day': b.all_day,
'time_from': b.time_from,
@@ -1485,11 +1514,15 @@ class SkeenFrontendController(http.Controller):
except (TypeError, ValueError):
continue
doctor_ids = list(dict.fromkeys(doctor_ids))
if not doctor_ids:
return json_response({'status': 'error', 'message': 'Selecciona al menos un médico'}, 400)
doctores = request.env['hr.employee'].sudo().browse(doctor_ids).exists()
if len(doctores) != len(doctor_ids):
return json_response({'status': 'error', 'message': 'Médico no encontrado'}, 404)
# Recursos (opcional): bloquear una cabina/sala/equipo sin médico.
recurso_ids = self._recursos_payload(data) if get_recursos(request.env)['activo'] else []
recursos = request.env['skeen.recurso'].sudo().browse(recurso_ids).exists()
if not doctores and not recursos:
return json_response(
{'status': 'error', 'message': 'Selecciona al menos un médico o un recurso'}, 400)
date_from = data.get('date_from') or data.get('date')
date_to = data.get('date_to') or date_from
@@ -1542,12 +1575,7 @@ class SkeenFrontendController(http.Controller):
grupo = str(uuid.uuid4())[:8]
Bloqueo = request.env['skeen.cita.bloqueo'].sudo()
creados = Bloqueo.browse()
creados = creados.with_context(
skeen_skip_bloqueo_citas=bool(data.get('force')),
).create([{
'doctor_id': doc.id,
'date': f.strftime('%Y-%m-%d'),
base = {
'all_day': all_day,
'time_from': time_from,
'time_to': time_to,
@@ -1555,10 +1583,25 @@ class SkeenFrontendController(http.Controller):
'motivo': motivo,
'grupo': grupo,
'created_by': request.frontend_user.login,
} for doc in doctores for f in fechas])
}
filas = []
# Un bloqueo por médico (sin recurso) y uno por recurso (sin médico):
# seleccionar médicos y recursos bloquea la unión de ambos.
for doc in doctores:
for f in fechas:
filas.append({**base, 'date': f.strftime('%Y-%m-%d'), 'doctor_id': doc.id})
for rec in recursos:
for f in fechas:
filas.append({**base, 'date': f.strftime('%Y-%m-%d'),
'recurso_ids': [(6, 0, [rec.id])]})
creados = Bloqueo.with_context(
skeen_skip_bloqueo_citas=bool(data.get('force')),
skeen_skip_recursos_solape=bool(data.get('force')),
).create(filas)
etiqueta = ', '.join(doctores.mapped('name') + recursos.mapped('name'))
self._auditar('agenda', 'skeen.cita.bloqueo', 'crear',
new_value='%s bloqueo(s)' % len(creados),
entity_label=', '.join(doctores.mapped('name')))
entity_label=etiqueta)
return json_response({
'status': 'success',
'grupo': grupo,
@@ -1585,8 +1628,8 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'Bloqueo no encontrado'}, 404)
data = _parse_json_body()
vals = {}
if 'doctor_id' in data and data['doctor_id']:
vals['doctor_id'] = int(data['doctor_id'])
if 'doctor_id' in data:
vals['doctor_id'] = int(data['doctor_id']) if data.get('doctor_id') else False
if data.get('date'):
vals['date'] = data['date']
if 'all_day' in data:
@@ -1599,13 +1642,21 @@ class SkeenFrontendController(http.Controller):
vals['motivo_tipo'] = data['motivo_tipo'] or 'otro'
if 'motivo' in data:
vals['motivo'] = data['motivo'] or ''
if 'recurso_ids' in data and get_recursos(request.env)['activo']:
vals['recurso_ids'] = [(6, 0, self._recursos_payload(data))]
if not vals:
return json_response({'status': 'error', 'message': 'Nada que actualizar'}, 400)
if not vals.get('doctor_id') and 'recurso_ids' not in vals and not bloqueo.recurso_ids:
return json_response(
{'status': 'error', 'message': 'Selecciona al menos un médico o un recurso'}, 400)
bloqueo.with_context(
skeen_skip_bloqueo_citas=bool(data.get('force')),
skeen_skip_recursos_solape=bool(data.get('force')),
).write(vals)
etiqueta = bloqueo.doctor_id.name if bloqueo.doctor_id else \
', '.join(bloqueo.recurso_ids.mapped('name'))
self._auditar('agenda', 'skeen.cita.bloqueo', 'editar',
entity_label=bloqueo.doctor_id.name,
entity_label=etiqueta,
new_value=str(vals))
return json_response({'status': 'success', 'bloqueo': self._bloqueo_to_dict(bloqueo)})
except ValidationError as e:
@@ -1623,7 +1674,10 @@ class SkeenFrontendController(http.Controller):
bloqueo = request.env['skeen.cita.bloqueo'].sudo().browse(bloqueo_id)
if not bloqueo.exists():
return json_response({'status': 'error', 'message': 'Bloqueo no encontrado'}, 404)
etiqueta = '%s · %s' % (bloqueo.doctor_id.name, bloqueo.date)
etiqueta = '%s · %s' % (
bloqueo.doctor_id.name if bloqueo.doctor_id
else ', '.join(bloqueo.recurso_ids.mapped('name')) or 'bloqueo',
bloqueo.date)
bloqueo.unlink()
self._auditar('agenda', 'skeen.cita.bloqueo', 'eliminar', entity_label=etiqueta)
return json_response({'status': 'success', 'message': 'Bloqueo eliminado'})
@@ -1651,6 +1705,14 @@ class SkeenFrontendController(http.Controller):
# ============================================================
# Lista de espera de pacientes
# ============================================================
def _sala_codigo(self, data):
"""Código de sala válido (de los recursos) o False si viene vacío/ajeno."""
value = data.get('sala')
if not value:
return False
validos = [c for c, _l in request.env['skeen.lista.espera']._get_sala_selection()]
return value if value in validos else False
def _lista_espera_to_dict(self, r):
return {
'id': r.id,
@@ -1666,7 +1728,7 @@ class SkeenFrontendController(http.Controller):
'priority_label': dict(r._fields['priority'].selection).get(r.priority, ''),
'franja': r.franja or '',
'sala': r.sala or '',
'sala_label': dict(r._fields['sala'].selection).get(r.sala, '') if r.sala else '',
'sala_label': r.sala_label(r.sala) if r.sala else '',
'date_from': r.date_from.strftime('%Y-%m-%d') if r.date_from else None,
'date_to': r.date_to.strftime('%Y-%m-%d') if r.date_to else None,
'notes': r.notes or '',
@@ -1751,7 +1813,7 @@ class SkeenFrontendController(http.Controller):
'servicio_id': int(data['service_id'] or data['servicio_id']) if (data.get('service_id') or data.get('servicio_id')) else False,
'priority': data.get('priority') or 'normal',
'franja': data.get('franja') or '',
'sala': data.get('sala') if data.get('sala') in ('consultoria', 'cabina') else False,
'sala': self._sala_codigo(data),
'date_from': data.get('date_from') or False,
'date_to': data.get('date_to') or False,
'notes': data.get('notes') or '',
@@ -1793,9 +1855,9 @@ class SkeenFrontendController(http.Controller):
vals[field] = data[key] or False
if 'sala' in data:
# Toggle de sala desde la etiqueta: '' vuelve a "Sin asignar"
vals['sala'] = data['sala'] if data['sala'] in ('consultoria', 'cabina') else False
vals['sala'] = self._sala_codigo(data)
if vals['sala']:
record._log('Sala: %s' % dict(record._fields['sala'].selection).get(vals['sala'], vals['sala']),
record._log('Sala: %s' % record.sala_label(vals['sala']),
user=request.frontend_user.login)
if data.get('cita_id'):
vals['cita_id'] = int(data['cita_id'])
@@ -1857,6 +1919,8 @@ class SkeenFrontendController(http.Controller):
'service_duration': cita.servicio_duration,
'doctor_id': cita.doctor_id.id if cita.doctor_id else None,
'doctor': cita.doctor_id.name if cita.doctor_id else None,
'recurso_ids': cita.recurso_ids.ids,
'recursos': [self._recurso_to_dict(r) for r in cita.recurso_ids],
'branch': cita.branch,
'medium': cita.medium,
'booking_source': cita.booking_source or 'internal',
@@ -5854,7 +5918,7 @@ class SkeenFrontendController(http.Controller):
])
bloqueo_horas = {}
for b in bloqueos:
if b.date.weekday() == 6:
if not b.doctor_id or b.date.weekday() == 6:
continue
horas = 8.0 if b.all_day else max(0.0, b.time_to - b.time_from)
bloqueo_horas[b.doctor_id.name] = bloqueo_horas.get(b.doctor_id.name, 0.0) + horas
@@ -7113,6 +7177,301 @@ class SkeenFrontendController(http.Controller):
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Recursos de la agenda (configurables: cabinas, salas, equipos)
# ============================================================
def _recursos_payload(self, data):
"""IDs de recurso de un body (lista `recurso_ids` o CSV `recurso_ids`).
Descarta valores no numéricos o inexistentes para no romper el guardado.
"""
raw = data.get('recurso_ids')
if raw is None:
raw = data.get('recurso_ids_csv')
ids = []
if isinstance(raw, (list, tuple)):
for v in raw:
try:
ids.append(int(v))
except (TypeError, ValueError):
continue
elif raw not in (None, ''):
for v in str(raw).split(','):
v = v.strip()
if v.isdigit():
ids.append(int(v))
ids = list(dict.fromkeys(ids))
if not ids:
return []
existentes = request.env['skeen.recurso'].sudo().search([('id', 'in', ids)]).ids
return [i for i in ids if i in existentes]
def _recurso_vals_cita(self, data):
"""`{recurso_ids: [(6,0,ids)]}` si el módulo está activo y hay recursos."""
cfg = get_recursos(request.env)
if not cfg['activo']:
return {}
ids = self._recursos_payload(data)
if not cfg['permitir_multiples']:
ids = ids[:1]
return {'recurso_ids': [(6, 0, ids)]} if ids else {}
def _recurso_to_dict(self, r):
return {
'id': r.id,
'code': r.code,
'name': r.name,
'tipo': r.tipo,
'tipo_label': dict(r._fields['tipo'].selection).get(r.tipo or '', ''),
'activo': r.activo,
'sequence': r.sequence,
'sucursales': [{'id': s.id, 'code': s.code, 'name': s.name} for s in r.sucursales],
'capacidad': r.capacidad or 0,
'costo_hora': r.costo_hora or 0.0,
'color': r.color or '',
'notas': r.notas or '',
}
@http.route('/skeen/frontend/v1/recursos', type='http',
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('recursos.ver')
def list_recursos(self, **kw):
"""Recursos. Por defecto solo activos; ?include_inactive=1 trae todos."""
try:
incluir = kw.get('include_inactive') in ('1', 'true', 'True')
domain = [] if incluir else [('activo', '=', True)]
recs = request.env['skeen.recurso'].sudo().search(domain, order='sequence, name')
return json_response({'status': 'success',
'recursos': [self._recurso_to_dict(r) for r in recs],
'total': len(recs)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/recursos', type='http',
auth='none', methods=['POST'], csrf=False)
@require_role('gerencia')
@require_perm('recursos.editar')
def create_recurso(self, **kw):
"""Crea un recurso. Sin `code` se genera un slug del nombre."""
try:
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
data = _parse_json_body()
name = (data.get('name') or '').strip()
if not name:
return json_response({'status': 'error',
'message': 'El nombre es obligatorio'}, 400)
Recurso = request.env['skeen.recurso'].sudo()
codigo = (data.get('code') or '').strip()
if codigo:
codigo = catalog_slugify(codigo) or codigo
if Recurso.search_count([('code', '=', codigo)]):
return json_response({
'status': 'error',
'message': 'Ya existe un recurso con el código %s' % codigo}, 409)
else:
codigo = catalog_unique_slug(Recurso, name)
vals = self._recurso_vals(data)
vals.update({'name': name, 'code': codigo})
recurso = Recurso.create(vals)
self._auditar('catalogos', 'recurso:%s' % recurso.id, 'crear',
new_value=json.dumps(self._recurso_to_dict(recurso),
ensure_ascii=False)[:500],
entity_label='Recurso %s' % recurso.name,
notes=(data.get('motivo') or 'creado').strip())
return json_response({'status': 'success',
'recurso': self._recurso_to_dict(recurso)}, 201)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
def _recurso_vals(self, data):
"""Campos comunes de alta/edición de un recurso (sin name/code)."""
vals = {}
if 'tipo' in data:
tipos = dict(request.env['skeen.recurso']._fields['tipo'].selection)
vals['tipo'] = data['tipo'] if data.get('tipo') in tipos else 'otro'
for campo in ('notas', 'color'):
if campo in data:
vals[campo] = str(data.get(campo) or '').strip()
if 'sequence' in data:
try:
vals['sequence'] = int(data['sequence'])
except (TypeError, ValueError):
raise ValueError('La secuencia debe ser numérica')
if 'capacidad' in data:
try:
vals['capacidad'] = max(0, int(data['capacidad'] or 0))
except (TypeError, ValueError):
raise ValueError('La capacidad debe ser un número entero')
if 'costo_hora' in data:
try:
vals['costo_hora'] = max(0.0, float(data['costo_hora'] or 0))
except (TypeError, ValueError):
raise ValueError('El costo por hora debe ser un número')
if 'activo' in data:
vals['activo'] = bool(data['activo'])
if 'sucursal_ids' in data:
ids = data.get('sucursal_ids') or []
if not isinstance(ids, list):
raise ValueError('sucursal_ids debe ser una lista')
vals['sucursales'] = [(6, 0, [int(i) for i in ids if str(i).isdigit()])]
return vals
@http.route('/skeen/frontend/v1/recursos/<int:recurso_id>', type='http',
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('recursos.editar')
def update_recurso(self, recurso_id, **kw):
try:
if request.httprequest.method == 'OPTIONS':
return json_response({}, 200)
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
recurso = request.env['skeen.recurso'].sudo().browse(recurso_id)
if not recurso.exists():
return json_response({'status': 'error',
'message': 'Recurso no encontrado'}, 404)
data = _parse_json_body()
vals = {}
if 'name' in data and str(data.get('name') or '').strip():
vals['name'] = str(data['name']).strip()
if 'code' in data:
codigo = catalog_slugify(data.get('code')) or str(data.get('code') or '').strip()
if codigo and codigo != recurso.code:
dup = request.env['skeen.recurso'].sudo().search_count(
[('code', '=', codigo), ('id', '!=', recurso.id)])
if dup:
return json_response({
'status': 'error',
'message': 'Ya existe un recurso con el código %s' % codigo}, 409)
vals['code'] = codigo
try:
vals.update(self._recurso_vals(data))
except ValueError as e:
return json_response({'status': 'error', 'message': str(e)}, 400)
if not vals:
return json_response({'status': 'error',
'message': 'Nada que actualizar'}, 400)
recurso.write(vals)
self._auditar('catalogos', 'recurso:%s' % recurso.id, 'editar',
new_value=json.dumps(self._recurso_to_dict(recurso),
ensure_ascii=False)[:500],
entity_label='Recurso %s' % recurso.name,
notes=(data.get('motivo') or 'actualizado').strip())
return json_response({'status': 'success',
'recurso': self._recurso_to_dict(recurso)})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/recursos/<int:recurso_id>', type='http',
auth='none', methods=['DELETE'], csrf=False)
@require_role('gerencia')
@require_perm('recursos.editar')
def delete_recurso(self, recurso_id, **kw):
"""Desactiva el recurso (nunca se borra: las citas lo referencian)."""
try:
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
recurso = request.env['skeen.recurso'].sudo().browse(recurso_id)
if not recurso.exists():
return json_response({'status': 'error',
'message': 'Recurso no encontrado'}, 404)
en_uso = request.env['skeen.cita'].sudo().search_count(
[('recurso_ids', 'in', recurso.id)])
recurso.write({'activo': False})
self._auditar('catalogos', 'recurso:%s' % recurso.id, 'desactivar',
old_value=json.dumps(self._recurso_to_dict(recurso),
ensure_ascii=False)[:500],
entity_label='Recurso %s' % recurso.name,
notes='desactivado (%d citas)' % en_uso)
mensaje = 'Recurso desactivado.'
if en_uso:
mensaje = ('El recurso tiene %d cita(s) registradas y no se puede '
'eliminar; quedó desactivado.' % en_uso)
return json_response({'status': 'success',
'recurso': self._recurso_to_dict(recurso),
'en_uso': en_uso, 'message': mensaje})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/recursos', type='http',
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_recursos(self, **kw):
"""Ocupación por recurso: citas, horas ocupadas, % y costo estimado.
Horas disponibles = días del rango × jornada de la clínica. El % de
ocupación es horas ocupadas / disponibles. `ingresos_estimados` usa el
`costo_hora` del recurso cuando está definido (0 si no).
"""
try:
start = kw.get('start', date.today().strftime('%Y-%m-%d'))
end = kw.get('end', start)
try:
d_ini = datetime.strptime(str(start)[:10], '%Y-%m-%d').date()
d_fin = datetime.strptime(str(end)[:10], '%Y-%m-%d').date()
except ValueError:
return json_response({'status': 'error', 'message': 'Fecha inválida'}, 400)
if d_fin < d_ini:
d_ini, d_fin = d_fin, d_ini
dias = (d_fin - d_ini).days + 1
ini_h, fin_h = self._agenda_bounds()
horas_dia = max(0.0, fin_h - ini_h)
disponibles = horas_dia * dias
citas = request.env['skeen.cita'].sudo().search([
('date', '>=', d_ini.strftime('%Y-%m-%d')),
('date', '<=', d_fin.strftime('%Y-%m-%d')),
('state', 'not in', INACTIVE_STATES),
])
acumulado = {}
for c in citas:
for r in c.recurso_ids:
acc = acumulado.setdefault(r.id, {'citas': 0, 'minutos': 0})
acc['citas'] += 1
acc['minutos'] += c._duracion()
recursos = request.env['skeen.recurso'].sudo().search(
[('activo', '=', True)], order='sequence, name')
filas = []
for r in recursos:
acc = acumulado.get(r.id, {'citas': 0, 'minutos': 0})
horas = round(acc['minutos'] / 60.0, 2)
ocupacion = round((horas / disponibles * 100.0), 1) if disponibles else 0.0
filas.append({
'recurso_id': r.id,
'recurso': r.name,
'code': r.code,
'tipo': r.tipo,
'tipo_label': dict(r._fields['tipo'].selection).get(r.tipo or '', ''),
'color': r.color or '',
'citas': acc['citas'],
'horas_ocupadas': horas,
'horas_disponibles': round(disponibles, 2),
'ocupacion_pct': min(ocupacion, 100.0),
'costo_hora': r.costo_hora or 0.0,
'ingresos_estimados': round(horas * (r.costo_hora or 0.0), 2),
})
total_horas = round(sum(f['horas_ocupadas'] for f in filas), 2)
total_citas = sum(f['citas'] for f in filas)
total_ingresos = round(sum(f['ingresos_estimados'] for f in filas), 2)
return json_response({
'status': 'success',
'start': d_ini.strftime('%Y-%m-%d'),
'end': d_fin.strftime('%Y-%m-%d'),
'dias': dias,
'horas_disponibles': round(disponibles, 2),
'recursos': filas,
'totales': {'citas': total_citas, 'horas_ocupadas': total_horas,
'ingresos_estimados': total_ingresos},
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Sucursales (configurables)
# ============================================================

View File

@@ -283,7 +283,9 @@ class SkeenPublicBookingController(http.Controller):
if request.httprequest.method == 'OPTIONS':
return _json({}, 200)
if not self._activo():
return self._deshabilitado()
# 200 con activo=false (en vez de 404): el frontend lo trata como
# "no configurado" y no deja un 404 en la consola del login.
return _json({'status': 'success', 'activo': False})
cfg = self._config()
settings = self._settings()
env = request.env

View File

@@ -38,6 +38,10 @@ PERMISSION_GROUPS = [
('agenda.todas', 'Ver la agenda de todos (no solo la propia)'),
('agenda.bloquear', 'Bloquear / cerrar agenda'),
]),
('recursos', 'Recursos de la agenda', [
('recursos.ver', 'Ver recursos (cabinas, salas, equipos)'),
('recursos.editar', 'Administrar recursos y su configuración'),
]),
('pacientes', 'Pacientes', [
('pacientes.ver', 'Ver pacientes'),
('pacientes.expediente', 'Ver expediente completo'),
@@ -121,6 +125,7 @@ PERM_LABELS = {
# Permisos que sólo son lectura: son los que conserva el rol 'lectura'.
READ_PERM_KEYS = (
'agenda.ver', 'agenda.todas',
'recursos.ver',
'pacientes.ver', 'pacientes.expediente', 'pacientes.exportar', 'pacientes.saldos',
'visitas.ver',
'inventario.ver',
@@ -168,6 +173,9 @@ DEFAULT_PERM_ROLES = {
'agenda.visitas': W,
'agenda.todas': W,
'agenda.bloquear': W,
# Recursos de la agenda: ver es de todos; administrarlos es de admin/gerencia.
'recursos.ver': ALL,
'recursos.editar': AG,
# Pacientes
'pacientes.ver': ALL,
'pacientes.expediente': ALL,