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 { GlosarioProvider } from './lib/glosario';
import { SucursalesProvider } from './lib/sucursales'; import { SucursalesProvider } from './lib/sucursales';
import { ServiceCategoriesProvider } from './lib/service-categories'; import { ServiceCategoriesProvider } from './lib/service-categories';
import { RecursosProvider } from './lib/recursos';
import { QuickLinksProvider } from './lib/quicklinks'; import { QuickLinksProvider } from './lib/quicklinks';
import { PatientDrawerProvider } from './components/PatientDrawer'; import { PatientDrawerProvider } from './components/PatientDrawer';
import ChatNotifications from './lib/chat-notifications'; import ChatNotifications from './lib/chat-notifications';
@@ -142,6 +143,7 @@ const App: FC = () => {
<GlosarioProvider> <GlosarioProvider>
<SucursalesProvider> <SucursalesProvider>
<ServiceCategoriesProvider> <ServiceCategoriesProvider>
<RecursosProvider>
<QuickLinksProvider> <QuickLinksProvider>
<Router> <Router>
<ToastProvider /> <ToastProvider />
@@ -187,6 +189,7 @@ const App: FC = () => {
</PatientDrawerProvider> </PatientDrawerProvider>
</Router> </Router>
</QuickLinksProvider> </QuickLinksProvider>
</RecursosProvider>
</ServiceCategoriesProvider> </ServiceCategoriesProvider>
</SucursalesProvider> </SucursalesProvider>
</GlosarioProvider> </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, Badge, Button, Input, Modal, TextArea, toast,
} from '../ui'; } from '../ui';
import { odooApi, type Bloqueo, type BloqueoMotivo, type Doctor } from '../../services/odoo'; 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 }[] = [ const MOTIVOS: { value: BloqueoMotivo; label: string }[] = [
{ value: 'vacaciones', label: 'Vacaciones' }, { value: 'vacaciones', label: 'Vacaciones' },
@@ -63,6 +65,9 @@ const BloqueosModal: FC<Props> = ({
isOpen, onClose, date, doctors, isMedico, ownDoctorId, initial, puedeGestionar = true, onChanged, isOpen, onClose, date, doctors, isMedico, ownDoctorId, initial, puedeGestionar = true, onChanged,
}) => { }) => {
const [doctorIds, setDoctorIds] = useState<number[]>([]); const [doctorIds, setDoctorIds] = useState<number[]>([]);
const [recursoIds, setRecursoIds] = useState<number[]>([]);
const { recursos } = useRecursos();
const { recursos: recursosConf } = useClinicSettings();
const [dateFrom, setDateFrom] = useState(date); const [dateFrom, setDateFrom] = useState(date);
const [dateTo, setDateTo] = useState(date); const [dateTo, setDateTo] = useState(date);
const [repite, setRepite] = useState(false); const [repite, setRepite] = useState(false);
@@ -80,6 +85,7 @@ const BloqueosModal: FC<Props> = ({
const resetForm = useCallback((fecha?: string) => { const resetForm = useCallback((fecha?: string) => {
setEditing(null); setEditing(null);
setDoctorIds(isMedico && ownDoctorId ? [ownDoctorId] : []); setDoctorIds(isMedico && ownDoctorId ? [ownDoctorId] : []);
setRecursoIds([]);
setDateFrom(fecha || date); setDateFrom(fecha || date);
setDateTo(fecha || date); setDateTo(fecha || date);
setRepite(false); setRepite(false);
@@ -112,6 +118,7 @@ const BloqueosModal: FC<Props> = ({
if (initial) { if (initial) {
setEditing(null); setEditing(null);
setDoctorIds(initial.doctorIds); setDoctorIds(initial.doctorIds);
setRecursoIds([]);
setDateFrom(date); setDateFrom(date);
setDateTo(date); setDateTo(date);
setRepite(false); setRepite(false);
@@ -135,6 +142,9 @@ const BloqueosModal: FC<Props> = ({
const toggleDoctor = (id: number) => { const toggleDoctor = (id: number) => {
setDoctorIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); 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) => { const toggleDay = (d: number) => {
setDays((prev) => (prev.includes(d) ? prev.filter((x) => x !== d) : [...prev, d])); setDays((prev) => (prev.includes(d) ? prev.filter((x) => x !== d) : [...prev, d]));
}; };
@@ -153,8 +163,8 @@ const BloqueosModal: FC<Props> = ({
}, [repite, dateFrom, rangeEnd, days]); }, [repite, dateFrom, rangeEnd, days]);
const submit = async (force = false) => { const submit = async (force = false) => {
if (doctorIds.length === 0) { if (doctorIds.length === 0 && recursoIds.length === 0) {
toast.error('Selecciona al menos un médico'); toast.error('Selecciona al menos un médico o un recurso');
return; return;
} }
if (!dateFrom) { if (!dateFrom) {
@@ -174,6 +184,7 @@ const BloqueosModal: FC<Props> = ({
if (editing) { if (editing) {
await odooApi.updateBloqueo(editing.id, { await odooApi.updateBloqueo(editing.id, {
doctor_id: doctorIds[0], doctor_id: doctorIds[0],
recurso_ids: recursoIds,
date: dateFrom, date: dateFrom,
all_day: allDay, all_day: allDay,
time_from: parseTimeToFloat(timeFrom), time_from: parseTimeToFloat(timeFrom),
@@ -186,6 +197,7 @@ const BloqueosModal: FC<Props> = ({
} else { } else {
const res = await odooApi.createBloqueo({ const res = await odooApi.createBloqueo({
doctor_ids: doctorIds, doctor_ids: doctorIds,
recurso_ids: recursoIds,
date_from: dateFrom, date_from: dateFrom,
date_to: repite ? rangeEnd : dateFrom, date_to: repite ? rangeEnd : dateFrom,
days: repite ? days : [], days: repite ? days : [],
@@ -223,7 +235,7 @@ const BloqueosModal: FC<Props> = ({
const remove = async (b: Bloqueo, serie = false) => { const remove = async (b: Bloqueo, serie = false) => {
const etiqueta = serie const etiqueta = serie
? `toda la serie (${b.grupo})` ? `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; if (!window.confirm(`¿Eliminar el bloqueo de ${etiqueta}?`)) return;
try { try {
if (serie && b.grupo) { if (serie && b.grupo) {
@@ -242,7 +254,8 @@ const BloqueosModal: FC<Props> = ({
const startEdit = (b: Bloqueo) => { const startEdit = (b: Bloqueo) => {
setEditing(b); setEditing(b);
setDoctorIds([b.doctor_id]); setDoctorIds(b.doctor_id ? [b.doctor_id] : []);
setRecursoIds(b.recurso_ids || []);
setDateFrom(b.date); setDateFrom(b.date);
setDateTo(b.date); setDateTo(b.date);
setRepite(false); setRepite(false);
@@ -297,7 +310,9 @@ const BloqueosModal: FC<Props> = ({
{/* Médicos (multi-selección) */} {/* Médicos (multi-selección) */}
<div> <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 ? ( {isMedico ? (
<p className="text-sm text-theme-heading"> <p className="text-sm text-theme-heading">
{doctors.find((d) => d.id === ownDoctorId)?.name || 'Mi agenda'} {doctors.find((d) => d.id === ownDoctorId)?.name || 'Mi agenda'}
@@ -334,6 +349,39 @@ const BloqueosModal: FC<Props> = ({
)} )}
</div> </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"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Input <Input
label={repite ? 'Desde *' : 'Fecha *'} 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" 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="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"> <span className="text-theme-muted">
{' · '}{b.all_day ? 'Todo el día' : `${horaFloatStr(b.time_from)}–${horaFloatStr(b.time_to)}`} {' · '}{b.all_day ? 'Todo el día' : `${horaFloatStr(b.time_from)}–${horaFloatStr(b.time_to)}`}
</span> </span>

View File

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

View File

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

View File

@@ -6,6 +6,7 @@
export const PERM_KEYS: string[] = [ export const PERM_KEYS: string[] = [
'agenda.ver', 'agenda.agendar', 'agenda.editar', 'agenda.eliminar', 'agenda.ver', 'agenda.agendar', 'agenda.editar', 'agenda.eliminar',
'agenda.mover', 'agenda.visitas', 'agenda.todas', 'agenda.bloquear', 'agenda.mover', 'agenda.visitas', 'agenda.todas', 'agenda.bloquear',
'recursos.ver', 'recursos.editar',
'pacientes.ver', 'pacientes.expediente', 'pacientes.crear', 'pacientes.editar', 'pacientes.ver', 'pacientes.expediente', 'pacientes.crear', 'pacientes.editar',
'pacientes.eliminar', 'pacientes.exportar', 'pacientes.saldos', 'pacientes.eliminar', 'pacientes.exportar', 'pacientes.saldos',
'visitas.ver', 'visitas.crear', 'visitas.editar', 'visitas.eliminar', 'visitas.archivos', '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([ const READ_PERM_KEYS = new Set([
'agenda.ver', 'agenda.todas', 'agenda.ver', 'agenda.todas',
'recursos.ver',
'pacientes.ver', 'pacientes.expediente', 'pacientes.exportar', 'pacientes.saldos', 'pacientes.ver', 'pacientes.expediente', 'pacientes.exportar', 'pacientes.saldos',
'visitas.ver', 'visitas.ver',
'inventario.ver', 'inventario.ver',
@@ -49,6 +51,7 @@ const READ_PERM_KEYS = new Set([
const DEFAULT_PERM_ROLES: Record<string, string[]> = { const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'agenda.ver': ALL, 'agenda.agendar': W, 'agenda.editar': W, 'agenda.eliminar': W, 'agenda.ver': ALL, 'agenda.agendar': W, 'agenda.editar': W, 'agenda.eliminar': W,
'agenda.mover': W, 'agenda.visitas': W, 'agenda.todas': W, 'agenda.bloquear': 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.ver': ALL, 'pacientes.expediente': ALL, 'pacientes.crear': W,
'pacientes.editar': W, 'pacientes.eliminar': W, 'pacientes.exportar': ALL, 'pacientes.editar': W, 'pacientes.eliminar': W, 'pacientes.exportar': ALL,
'pacientes.saldos': 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. // responden 404 cuando el módulo está desactivado.
/** /**
* Config del portal público. Devuelve `config=null` cuando está desactivado * Config del portal público. Devuelve `config=null` cuando está desactivado o
* (404) o el backend no responde: en ese caso el portal se muestra como "no * el backend no responde: en ese caso el portal se muestra como "no
* disponible" y no se ofrece ningún enlace. * 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 = () => { export const useBookingConfig = () => {
const [config, setConfig] = useState<BookingConfig | null>(null); 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 type { FC } from 'react';
import { useEffect, useState, useCallback, useRef, useMemo } 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 Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer'; import { usePatientDrawer } from '../components/PatientDrawer';
import AgendaPrint from '../components/AgendaPrint'; import AgendaPrint from '../components/AgendaPrint';
@@ -32,6 +32,7 @@ import { useAuth } from '../lib/auth';
import { useGlosario } from '../lib/glosario'; import { useGlosario } from '../lib/glosario';
import { useClinicSettings } from '../lib/clinic-settings'; import { useClinicSettings } from '../lib/clinic-settings';
import { useSucursales } from '../lib/sucursales'; import { useSucursales } from '../lib/sucursales';
import { useRecursos } from '../lib/recursos';
import { todayLocal } from '../lib/utils'; import { todayLocal } from '../lib/utils';
import { useAutoRefresh, AutoRefreshBadge } from '../lib/autoRefresh'; import { useAutoRefresh, AutoRefreshBadge } from '../lib/autoRefresh';
@@ -88,10 +89,11 @@ const stateOptions = [
// ---- Preferencias persistidas de la agenda (vista y filtros, NO la fecha) ---- // ---- Preferencias persistidas de la agenda (vista y filtros, NO la fecha) ----
const AGENDA_PREFS_KEY = 'skeen_agenda_prefs'; const AGENDA_PREFS_KEY = 'skeen_agenda_prefs';
type AgendaViewMode = 'lista' | 'doctores' | 'horas' | 'semana'; type AgendaViewMode = 'lista' | 'doctores' | 'horas' | 'semana' | 'recursos';
interface AgendaPrefs { interface AgendaPrefs {
viewMode?: AgendaViewMode; viewMode?: AgendaViewMode;
doctorFilter?: string; doctorFilter?: string;
recursoFilter?: string;
staffTypeFilter?: '' | 'doctor' | 'cosmetologa'; staffTypeFilter?: '' | 'doctor' | 'cosmetologa';
} }
const readAgendaPrefs = (): AgendaPrefs => { 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 ---- // ---- Conflictos de agenda (solape / bloqueo) en lenguaje humano ----
export interface AgendaConflict { export interface AgendaConflict {
@@ -161,7 +163,12 @@ const Agenda: FC = () => {
const { user, canAgenda, can } = useAuth(); const { user, canAgenda, can } = useAuth();
const { term } = useGlosario(); const { term } = useGlosario();
const { sucursales, defaultCode } = useSucursales(); const { sucursales, defaultCode } = useSucursales();
const { recursos } = useRecursos();
const clinicSettings = useClinicSettings(); 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(); const { openPatient } = usePatientDrawer();
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor). // 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 // Cosmetología ve la agenda completa (es personal clínico, sin la privacidad
@@ -205,6 +212,10 @@ const Agenda: FC = () => {
const saved = readAgendaPrefs().staffTypeFilter; const saved = readAgendaPrefs().staffTypeFilter;
return saved === 'doctor' || saved === 'cosmetologa' ? saved : ''; 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 [viewMode, setViewMode] = useState<AgendaViewMode>(() => {
const saved = readAgendaPrefs().viewMode; const saved = readAgendaPrefs().viewMode;
return saved && VALID_VIEW_MODES.includes(saved) ? saved : 'doctores'; return saved && VALID_VIEW_MODES.includes(saved) ? saved : 'doctores';
@@ -216,11 +227,12 @@ const Agenda: FC = () => {
viewMode, viewMode,
staffTypeFilter, staffTypeFilter,
doctorFilter: isMedico ? undefined : doctorFilter, doctorFilter: isMedico ? undefined : doctorFilter,
recursoFilter,
} as AgendaPrefs)); } as AgendaPrefs));
} catch { } catch {
// localStorage no disponible: ignorar // localStorage no disponible: ignorar
} }
}, [viewMode, staffTypeFilter, doctorFilter, isMedico]); }, [viewMode, staffTypeFilter, doctorFilter, isMedico, recursoFilter]);
const [quickFilter, setQuickFilter] = useState<'' | 'libres' | 'primera' | 'checkin' | 'noshow' | 'porconfirmar' | 'cancelled' | 'waitlist'>(''); const [quickFilter, setQuickFilter] = useState<'' | 'libres' | 'primera' | 'checkin' | 'noshow' | 'porconfirmar' | 'cancelled' | 'waitlist'>('');
// Confirmación telefónica en lote (solo vista Lista) // Confirmación telefónica en lote (solo vista Lista)
const [selectedIds, setSelectedIds] = useState<number[]>([]); const [selectedIds, setSelectedIds] = useState<number[]>([]);
@@ -277,6 +289,7 @@ const Agenda: FC = () => {
sessions: 1, sessions: 1,
duration: '', duration: '',
branch: '', branch: '',
recurso_ids: [] as number[],
}); });
const quickToggleClass = editingId const quickToggleClass = editingId
? 'hidden' ? 'hidden'
@@ -316,6 +329,7 @@ const Agenda: FC = () => {
const filteredAppointments = appointments.filter((apt) => { const filteredAppointments = appointments.filter((apt) => {
if (stateFilter && apt.state !== stateFilter) return false; if (stateFilter && apt.state !== stateFilter) return false;
if (doctorFilter && String(apt.doctor_id) !== doctorFilter) return false; if (doctorFilter && String(apt.doctor_id) !== doctorFilter) return false;
if (recursoFilter && !(apt.recurso_ids || []).includes(Number(recursoFilter))) return false;
return true; 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" /> <Globe size={size} className="inline shrink-0 text-sky-600" aria-label="Reservada en línea por el cliente" />
) : null; ) : 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 => { const confirmTitle = (apt: Appointment): string => {
if (['arrived', 'in_progress', 'done'].includes(apt.state)) return '2da confirmación: llegó a la clínica'; if (['arrived', 'in_progress', 'done'].includes(apt.state)) return '2da confirmación: llegó a la clínica';
if (apt.phone_confirmed) { if (apt.phone_confirmed) {
@@ -519,6 +550,31 @@ const Agenda: FC = () => {
const gridDoctorCount = (doctorId: number | null) => const gridDoctorCount = (doctorId: number | null) =>
gridAppointments.filter((a) => (doctorId ? a.doctor_id === doctorId : !a.doctor_id)).length; 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) // Bloqueo que cubre una celda (médico × bloque de 15 min)
const bloqueoEnCelda = (doctorId: number | null, slot: number) => const bloqueoEnCelda = (doctorId: number | null, slot: number) =>
doctorId 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 () => { const createAppointment = async () => {
if (!form.patient_id || !form.service_id || !form.date || !form.time) { if (!form.patient_id || !form.service_id || !form.date || !form.time) {
toast.error('Completa los campos obligatorios'); toast.error('Completa los campos obligatorios');
return; 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 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); const sessions = form.sessions > 1 ? form.sessions : ((service?.package_sessions ?? 0) > 1 ? (service?.package_sessions ?? 1) : 1);
try { try {
@@ -1474,6 +1543,7 @@ const Agenda: FC = () => {
sessions, sessions,
duration: form.duration ? parseInt(form.duration, 10) : 0, duration: form.duration ? parseInt(form.duration, 10) : 0,
branch: form.branch || defaultCode || undefined, branch: form.branch || defaultCode || undefined,
...(recursosActivos ? { recurso_ids: form.recurso_ids } : {}),
} as never); } as never);
toast.success(sessions > 1 ? `Paquete creado: ${sessions} citas` : 'Cita creada'); toast.success(sessions > 1 ? `Paquete creado: ${sessions} citas` : 'Cita creada');
// Si la cita salió de la lista de espera, se marca como agendada // Si la cita salió de la lista de espera, se marca como agendada
@@ -1489,6 +1559,7 @@ const Agenda: FC = () => {
sessions: 1, sessions: 1,
duration: '', duration: '',
branch: '', branch: '',
recurso_ids: [],
}); });
await load(); await load();
} catch (err) { } catch (err) {
@@ -1516,6 +1587,7 @@ const Agenda: FC = () => {
sessions: 1, sessions: 1,
duration: String(apt.duration || apt.service_duration || DEFAULT_DURATION), duration: String(apt.duration || apt.service_duration || DEFAULT_DURATION),
branch: apt.branch || '', branch: apt.branch || '',
recurso_ids: apt.recurso_ids || [],
}); });
setCreateOpen(true); setCreateOpen(true);
}; };
@@ -1526,6 +1598,10 @@ const Agenda: FC = () => {
toast.error('Completa los campos obligatorios'); toast.error('Completa los campos obligatorios');
return; return;
} }
if (recursosActivos && recursosConf.obligatorio_al_agendar && form.recurso_ids.length === 0) {
toast.error(`Selecciona al menos un ${recursoTerm}`);
return;
}
try { try {
setSubmitting(true); setSubmitting(true);
await odooApi.updateAppointment(editingId, { await odooApi.updateAppointment(editingId, {
@@ -1537,6 +1613,7 @@ const Agenda: FC = () => {
notes: form.notes, notes: form.notes,
duration: form.duration ? parseInt(form.duration, 10) : 0, duration: form.duration ? parseInt(form.duration, 10) : 0,
branch: form.branch || defaultCode || undefined, branch: form.branch || defaultCode || undefined,
...(recursosActivos ? { recurso_ids: form.recurso_ids } : {}),
} as never); } as never);
toast.success('Cita actualizada'); toast.success('Cita actualizada');
setCreateOpen(false); setCreateOpen(false);
@@ -1556,7 +1633,7 @@ const Agenda: FC = () => {
setQuickMode(false); setQuickMode(false);
setEditingId(null); setEditingId(null);
setPickedPatient(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({ setQuickForm({
name: '', name: '',
phone: '', phone: '',
@@ -1584,7 +1661,7 @@ const Agenda: FC = () => {
? String(medicoEmployeeId) ? String(medicoEmployeeId)
: (opts?.doctorId != null ? String(opts.doctorId) : doctorFilter); : (opts?.doctorId != null ? String(opts.doctorId) : doctorFilter);
const t = opts?.time ?? ''; 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({ setQuickForm({
name: '', name: '',
phone: '', phone: '',
@@ -1931,6 +2008,17 @@ const Agenda: FC = () => {
<CalendarRange size={16} className="mr-1" /> <CalendarRange size={16} className="mr-1" />
Semana Semana
</Button> </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>
</div> </div>
@@ -2014,6 +2102,51 @@ const Agenda: FC = () => {
)} )}
</div> </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 */} {/* Filtros rápidos */}
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5"> <div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
{(viewMode === 'doctores' || viewMode === 'horas') && ( {(viewMode === 'doctores' || viewMode === 'horas') && (
@@ -2285,6 +2418,7 @@ const Agenda: FC = () => {
</div> </div>
<div className="truncate opacity-75"> <div className="truncate opacity-75">
{apt.service} {apt.service}
{recursoDots(apt)}
{(apt.duration || DEFAULT_DURATION) > DEFAULT_DURATION && ( {(apt.duration || DEFAULT_DURATION) > DEFAULT_DURATION && (
<span className="ml-1 opacity-90">· {apt.duration} min</span> <span className="ml-1 opacity-90">· {apt.duration} min</span>
)} )}
@@ -2551,6 +2685,118 @@ const Agenda: FC = () => {
{gridLegend} {gridLegend}
</div> </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 ? ( ) : visibleAppointments.length === 0 ? (
<EmptyState <EmptyState
title="Sin citas" title="Sin citas"
@@ -2893,6 +3139,48 @@ const Agenda: FC = () => {
onChange={(e) => setForm({ ...form, doctor_id: e.target.value })} onChange={(e) => setForm({ ...form, doctor_id: e.target.value })}
disabled={isMedico} 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 <Select
label={term('branch_singular')} label={term('branch_singular')}
options={sucursales.map((s) => ({ value: s.code, label: s.name }))} 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-xs text-theme-muted mb-1">Médico</p>
<p className="text-sm font-medium text-theme-heading">{detailApt.doctor || 'Sin asignar'}</p> <p className="text-sm font-medium text-theme-heading">{detailApt.doctor || 'Sin asignar'}</p>
</div> </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"> <div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Duración</p> <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> <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 ImpactoModal from '../components/ImpactoModal';
import SucursalesPanel from '../components/SucursalesPanel'; import SucursalesPanel from '../components/SucursalesPanel';
import CategoriasPanel from '../components/CategoriasPanel'; import CategoriasPanel from '../components/CategoriasPanel';
import RecursosPanel from '../components/RecursosPanel';
import TerminologiaPanel from '../components/TerminologiaPanel'; import TerminologiaPanel from '../components/TerminologiaPanel';
import ReservasOnlinePanel from '../components/ReservasOnlinePanel'; import ReservasOnlinePanel from '../components/ReservasOnlinePanel';
import NotificacionesPanel from '../components/NotificacionesPanel'; import NotificacionesPanel from '../components/NotificacionesPanel';
@@ -31,6 +32,7 @@ const TABS = [
{ key: 'formularios', label: 'Formularios' }, { key: 'formularios', label: 'Formularios' },
{ key: 'sucursales', label: 'Sucursales' }, { key: 'sucursales', label: 'Sucursales' },
{ key: 'categorias', label: 'Categorías' }, { key: 'categorias', label: 'Categorías' },
{ key: 'recursos', label: 'Recursos' },
{ key: 'membresias', label: 'Membresías' }, { key: 'membresias', label: 'Membresías' },
{ key: 'terminologia', label: 'Terminología' }, { key: 'terminologia', label: 'Terminología' },
{ key: 'usuarios', label: 'Usuarios' }, { key: 'usuarios', label: 'Usuarios' },
@@ -42,7 +44,7 @@ const TABS = [
] as const; ] as const;
// Secciones que ve gerencia (admin del sistema ve todas). // 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 = [ const SUBCATALOGOS = [
{ key: 'diagnosticos', label: 'Diagnósticos' }, { key: 'diagnosticos', label: 'Diagnósticos' },
@@ -67,6 +69,7 @@ const Configuracion: FC = () => {
notif_enviadas: 'notificaciones.ver', notif_enviadas: 'notificaciones.ver',
formularios: 'formularios.ver', formularios: 'formularios.ver',
membresias: 'membresias.editar', membresias: 'membresias.editar',
recursos: 'recursos.editar',
}; };
const tabsVisibles = useMemo( const tabsVisibles = useMemo(
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key))) () => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key)))
@@ -382,6 +385,12 @@ const Configuracion: FC = () => {
<CategoriasPanel /> <CategoriasPanel />
</Card.Body> </Card.Body>
</Card> </Card>
) : tab === 'recursos' ? (
<Card>
<Card.Body>
<RecursosPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'membresias' ? ( ) : tab === 'membresias' ? (
<Card> <Card>
<Card.Body> <Card.Body>

View File

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

View File

@@ -70,6 +70,9 @@ export interface Appointment {
is_first_visit?: boolean; is_first_visit?: boolean;
doctor_id?: number | null; doctor_id?: number | null;
doctor?: string; doctor?: string;
/** Recursos asignados (cabina, sala, equipo). Vacío si el módulo está apagado. */
recurso_ids?: number[];
recursos?: RecursoBrief[];
branch?: string; branch?: string;
medium?: string; medium?: string;
/** Origen de la reserva: 'internal' (default) o 'public_portal' (portal en línea) */ /** 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 */ /** Motivos predefinidos de un bloqueo de agenda */
export type BloqueoMotivo = 'vacaciones' | 'junta' | 'comida' | 'personal' | 'otro'; 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 { export interface Bloqueo {
id: number; id: number;
doctor_id: number; doctor_id: number | null;
doctor: string; doctor: string | null;
/** Recursos bloqueados (cabina, sala, equipo). Vacío si solo bloquea médico. */
recurso_ids?: number[];
recursos?: RecursoBrief[];
date: string; date: string;
all_day: boolean; all_day: boolean;
time_from: number; time_from: number;
@@ -124,8 +177,8 @@ export interface ListaEspera {
priority: 'alta' | 'normal' | 'baja'; priority: 'alta' | 'normal' | 'baja';
priority_label: string; priority_label: string;
franja: '' | 'manana' | 'tarde'; franja: '' | 'manana' | 'tarde';
// Dónde espera: consultoría (médico) o cabina (tratamiento) // Dónde espera: el código de un recurso de la agenda (cabina, sala...)
sala?: '' | 'consultoria' | 'cabina'; sala?: string;
sala_label?: string; sala_label?: string;
date_from: string | null; date_from: string | null;
date_to: string | null; date_to: string | null;
@@ -1015,6 +1068,7 @@ export interface ClinicSettings {
reservas_online: ReservasOnlineSettings; reservas_online: ReservasOnlineSettings;
notificaciones: NotificacionesSettings; notificaciones: NotificacionesSettings;
membresias: MembresiasSettings; membresias: MembresiasSettings;
recursos: RecursosSettings;
} }
// ---- Reservas en línea (portal público de auto-agendado) ---- // ---- 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 ---- // ---- 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 NotificacionCanal = 'whatsapp' | 'email';
export type NotificacionCanalConfig = NotificacionCanal | 'ambos'; export type NotificacionCanalConfig = NotificacionCanal | 'ambos';
export type NotificacionTipo = 'recordatorio' | 'confirmacion' | 'cumpleanos' | 'post_visita' | 'recuperacion' | 'manual'; 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, reservas_online: RESERVAS_ONLINE_DEFAULT,
notificaciones: NOTIFICACIONES_DEFAULT, notificaciones: NOTIFICACIONES_DEFAULT,
membresias: MEMBRESIAS_DEFAULT, membresias: MEMBRESIAS_DEFAULT,
recursos: RECURSOS_DEFAULT,
}; };
export interface FrontendUser { export interface FrontendUser {
@@ -2190,6 +2285,40 @@ export const odooApi = {
return data; 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 ---- // ---- Ajustes de la clínica ----
async getClinicSettings(): Promise<{ status: string; settings: ClinicSettings; message?: string }> { async getClinicSettings(): Promise<{ status: string; settings: ClinicSettings; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/clinic-settings`); const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/clinic-settings`);
@@ -2492,7 +2621,7 @@ export const odooApi = {
return data; 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); const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/quick`, payload);
return data; return data;
}, },
@@ -2596,6 +2725,7 @@ export const odooApi = {
async createBloqueo(payload: { async createBloqueo(payload: {
doctor_ids: number[]; doctor_ids: number[];
recurso_ids?: number[];
date_from: string; date_from: string;
date_to?: string; date_to?: string;
days?: number[]; days?: number[];
@@ -2611,7 +2741,7 @@ export const odooApi = {
}, },
async updateBloqueo(id: number, payload: Partial<{ 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; motivo_tipo: BloqueoMotivo; motivo: string; force: boolean;
}>): Promise<{ status: string; bloqueo: Bloqueo }> { }>): Promise<{ status: string; bloqueo: Bloqueo }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/bloqueos/${id}`, payload); const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/bloqueos/${id}`, payload);

View File

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

View File

@@ -606,6 +606,66 @@ def get_membresias(env):
"""Bloque de membresías vigente (copia). Fuente única para modelos y API.""" """Bloque de membresías vigente (copia). Fuente única para modelos y API."""
return normalizar_membresias(get_settings(env).get('membresias')) 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). # Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
AGENDA_START_DEFAULT = 8.0 AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0 AGENDA_END_DEFAULT = 19.0
@@ -649,6 +709,7 @@ def get_settings(env):
'reservas_online': dict(RESERVAS_ONLINE_DEFAULT), 'reservas_online': dict(RESERVAS_ONLINE_DEFAULT),
'notificaciones': normalizar_notificaciones(None), 'notificaciones': normalizar_notificaciones(None),
'membresias': normalizar_membresias(None), 'membresias': normalizar_membresias(None),
'recursos': dict(RECURSOS_DEFAULT),
} }
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY) raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw: if raw:
@@ -664,6 +725,7 @@ def get_settings(env):
out['reservas_online'] = normalizar_reservas_online(guardado.get('reservas_online')) out['reservas_online'] = normalizar_reservas_online(guardado.get('reservas_online'))
out['notificaciones'] = normalizar_notificaciones(guardado.get('notificaciones')) out['notificaciones'] = normalizar_notificaciones(guardado.get('notificaciones'))
out['membresias'] = normalizar_membresias(guardado.get('membresias')) out['membresias'] = normalizar_membresias(guardado.get('membresias'))
out['recursos'] = normalizar_recursos(guardado.get('recursos'))
return out return out
@@ -692,6 +754,10 @@ def set_settings(env, vals):
bloque = validar_membresias(vals['membresias']) bloque = validar_membresias(vals['membresias'])
if bloque is not None: if bloque is not None:
actual['membresias'] = {**actual['membresias'], **bloque} 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( env['ir.config_parameter'].sudo().set_param(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False)) SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env) return get_settings(env)

View File

@@ -45,5 +45,24 @@
<field name="sequence">40</field> <field name="sequence">40</field>
<field name="is_system" eval="True"/> <field name="is_system" eval="True"/>
</record> </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> </data>
</odoo> </odoo>

View File

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

View File

@@ -3,6 +3,8 @@
from odoo import models, fields, api, _ from odoo import models, fields, api, _
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
from ..clinic_settings import get_recursos
# Motivos predefinidos (el legacy usaba 7 = Vacaciones y 8 = Horario de comida) # Motivos predefinidos (el legacy usaba 7 = Vacaciones y 8 = Horario de comida)
MOTIVO_SELECTION = [ MOTIVO_SELECTION = [
('vacaciones', 'Vacaciones'), ('vacaciones', 'Vacaciones'),
@@ -14,17 +16,24 @@ MOTIVO_SELECTION = [
class SkeenCitaBloqueo(models.Model): 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 Un registro = una fecha, con un médico (opcional) y/o recursos (opcional).
médicos y/o un rango de fechas con repetición semanal) comparten el campo Los bloqueos creados juntos (varios médicos/recursos y/o un rango de fechas
`grupo`, lo que permite editarlos o eliminarlos como serie. 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' _name = 'skeen.cita.bloqueo'
_description = 'Bloqueo de Agenda por Médico' _description = 'Bloqueo de Agenda'
_order = 'date desc, time_from' _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) date = fields.Date(string='Fecha', required=True, index=True)
all_day = fields.Boolean(string='Todo el día', default=False) all_day = fields.Boolean(string='Todo el día', default=False)
time_from = fields.Float(string='Desde', default=9.0) 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_tipo = fields.Selection(MOTIVO_SELECTION, string='Motivo', default='otro')
motivo = fields.Char(string='Detalle del motivo') motivo = fields.Char(string='Detalle del motivo')
grupo = fields.Char(string='Grupo', index=True, copy=False, 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) created_by = fields.Char(string='Creado por', readonly=True, copy=False)
@api.constrains('time_from', 'time_to', 'all_day') @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: if b.time_from < 0 or b.time_to > 24:
raise ValidationError(_('Las horas deben estar dentro del día')) 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') @api.constrains('doctor_id', 'date', 'all_day', 'time_from', 'time_to')
def _check_sin_citas(self): 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 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 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.' 'Reprograma o cancela esas citas antes de bloquear.'
) % {'n': len(citas), 'doc': b.doctor_id.name}) ) % {'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): def nombre_motivo(self):
"""Texto legible del motivo para la UI y los mensajes de validación.""" """Texto legible del motivo para la UI y los mensajes de validación."""
self.ensure_one() self.ensure_one()

View File

@@ -4,7 +4,7 @@ from odoo import models, fields, api, _
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
from datetime import datetime, timedelta 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. # Estados que liberan el horario: no ocupan agenda ni bloquean solapes.
@@ -134,6 +134,11 @@ class SkeenCita(models.Model):
string='Sucursal', string='Sucursal',
default=lambda self: self.env['skeen.sucursal'].default_code()) default=lambda self: self.env['skeen.sucursal'].default_code())
doctor_id = fields.Many2one('hr.employee', string='Médico / Especialista') 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([ medium = fields.Selection([
('videocall', 'Videollamada'), ('videocall', 'Videollamada'),
('onsite', 'Presencial'), ('onsite', 'Presencial'),
@@ -432,6 +437,52 @@ class SkeenCita(models.Model):
if b.all_day or (ini < b.time_to and fin > b.time_from): 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()) 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') @api.constrains('date', 'time', 'doctor_id', 'servicio_id', 'duration')
def _check_horario_medico(self): def _check_horario_medico(self):
"""Rechaza citas fuera del horario semanal del médico. """Rechaza citas fuera del horario semanal del médico.

View File

@@ -45,13 +45,13 @@ class SkeenListaEspera(models.Model):
('tarde', 'Tarde'), ('tarde', 'Tarde'),
], string='Preferencia', default='') ], string='Preferencia', default='')
# Dónde espera el paciente: consultoría (consulta con el médico) o cabina # Dónde espera el paciente: sale del catálogo de recursos (cabina, sala,
# (tratamiento/valoración). Recepción lo mueve con un click en la etiqueta. # consultorio...) para que el white-label pueda renombrarlos. Los códigos
sala = fields.Selection([ # semilla 'cabina'/'consultoria' conservan el comportamiento y los datos
('', 'Sin asignar'), # históricos; sin recursos configurados cae a los valores de siempre.
('consultoria', 'Consultoría'), sala = fields.Selection(
('cabina', 'Cabina'), selection='_get_sala_selection',
], string='Sala de espera', default='', index=True) string='Sala de espera', default='', index=True)
date_from = fields.Date(string='Desde') date_from = fields.Date(string='Desde')
date_to = fields.Date(string='Hasta') date_to = fields.Date(string='Hasta')
@@ -88,6 +88,19 @@ class SkeenListaEspera(models.Model):
) % {'nombre': rec.partner_id.name, ) % {'nombre': rec.partner_id.name,
'doc': rec.doctor_id.name or 'sin médico asignado'}) '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') @api.depends('priority')
def _compute_priority_rank(self): def _compute_priority_rank(self):
for rec in 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_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_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_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> <tree>
<field name="date"/> <field name="date"/>
<field name="doctor_id"/> <field name="doctor_id"/>
<field name="recurso_ids" widget="many2many_tags"/>
<field name="all_day"/> <field name="all_day"/>
<field name="time_from" widget="float_time"/> <field name="time_from" widget="float_time"/>
<field name="time_to" widget="float_time"/> <field name="time_to" widget="float_time"/>
@@ -29,6 +30,8 @@
<group> <group>
<group> <group>
<field name="doctor_id"/> <field name="doctor_id"/>
<field name="recurso_ids" widget="many2many_tags"
options="{'no_create': True}"/>
<field name="date"/> <field name="date"/>
<field name="motivo_tipo"/> <field name="motivo_tipo"/>
<field name="motivo"/> <field name="motivo"/>

View File

@@ -46,6 +46,8 @@
<field name="servicio_id"/> <field name="servicio_id"/>
<field name="servicio_price" readonly="1"/> <field name="servicio_price" readonly="1"/>
<field name="servicio_duration" readonly="1"/> <field name="servicio_duration" readonly="1"/>
<field name="recurso_ids" widget="many2many_tags"
options="{'no_create': True}"/>
<field name="duration"/> <field name="duration"/>
<field name="date"/> <field name="date"/>
<field name="time" widget="float_time"/> <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, get_settings as get_clinic_settings,
set_settings as set_clinic_settings, set_settings as set_clinic_settings,
get_impuestos, get_impuestos,
get_recursos,
) )
from odoo.addons.skeen_citas.glosario import ( from odoo.addons.skeen_citas.glosario import (
GLOSARIO_KEYS as GLOSARIO_KEY_SET, GLOSARIO_KEYS as GLOSARIO_KEY_SET,
@@ -930,6 +931,19 @@ class SkeenFrontendController(http.Controller):
'state': data.get('state', 'pending'), 'state': data.get('state', 'pending'),
'duration': int(data.get('duration') or 0), '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_date = data.get('date')
first_time = data.get('time') first_time = data.get('time')
if first_time is not None and not self._valid_agenda_time(first_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', ''), 'notes': data.get('notes', ''),
'state': data.get('state', 'pending'), 'state': data.get('state', 'pending'),
'duration': int(data.get('duration') or 0), 'duration': int(data.get('duration') or 0),
**self._recurso_vals_cita(data),
}) })
return json_response({ return json_response({
'status': 'success', 'status': 'success',
@@ -1092,6 +1107,17 @@ class SkeenFrontendController(http.Controller):
data = _parse_json_body() data = _parse_json_body()
allowed = ['partner_id', 'servicio_id', 'date', 'time', 'doctor_id', 'branch', 'medium', 'notes', 'state', 'duration'] 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} 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']): if 'time' in vals and not self._valid_agenda_time(vals['time']):
return json_response( return json_response(
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (%s)' % self._agenda_horario_txt()}, 400) {'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): def _bloqueo_to_dict(self, b):
return { return {
'id': b.id, 'id': b.id,
'doctor_id': b.doctor_id.id, 'doctor_id': b.doctor_id.id if b.doctor_id else None,
'doctor': b.doctor_id.name, '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, 'date': b.date.strftime('%Y-%m-%d') if b.date else None,
'all_day': b.all_day, 'all_day': b.all_day,
'time_from': b.time_from, 'time_from': b.time_from,
@@ -1485,11 +1514,15 @@ class SkeenFrontendController(http.Controller):
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
doctor_ids = list(dict.fromkeys(doctor_ids)) 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() doctores = request.env['hr.employee'].sudo().browse(doctor_ids).exists()
if len(doctores) != len(doctor_ids): if len(doctores) != len(doctor_ids):
return json_response({'status': 'error', 'message': 'Médico no encontrado'}, 404) 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_from = data.get('date_from') or data.get('date')
date_to = data.get('date_to') or date_from date_to = data.get('date_to') or date_from
@@ -1542,12 +1575,7 @@ class SkeenFrontendController(http.Controller):
grupo = str(uuid.uuid4())[:8] grupo = str(uuid.uuid4())[:8]
Bloqueo = request.env['skeen.cita.bloqueo'].sudo() Bloqueo = request.env['skeen.cita.bloqueo'].sudo()
creados = Bloqueo.browse() base = {
creados = creados.with_context(
skeen_skip_bloqueo_citas=bool(data.get('force')),
).create([{
'doctor_id': doc.id,
'date': f.strftime('%Y-%m-%d'),
'all_day': all_day, 'all_day': all_day,
'time_from': time_from, 'time_from': time_from,
'time_to': time_to, 'time_to': time_to,
@@ -1555,10 +1583,25 @@ class SkeenFrontendController(http.Controller):
'motivo': motivo, 'motivo': motivo,
'grupo': grupo, 'grupo': grupo,
'created_by': request.frontend_user.login, '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', self._auditar('agenda', 'skeen.cita.bloqueo', 'crear',
new_value='%s bloqueo(s)' % len(creados), new_value='%s bloqueo(s)' % len(creados),
entity_label=', '.join(doctores.mapped('name'))) entity_label=etiqueta)
return json_response({ return json_response({
'status': 'success', 'status': 'success',
'grupo': grupo, 'grupo': grupo,
@@ -1585,8 +1628,8 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'Bloqueo no encontrado'}, 404) return json_response({'status': 'error', 'message': 'Bloqueo no encontrado'}, 404)
data = _parse_json_body() data = _parse_json_body()
vals = {} vals = {}
if 'doctor_id' in data and data['doctor_id']: if 'doctor_id' in data:
vals['doctor_id'] = int(data['doctor_id']) vals['doctor_id'] = int(data['doctor_id']) if data.get('doctor_id') else False
if data.get('date'): if data.get('date'):
vals['date'] = data['date'] vals['date'] = data['date']
if 'all_day' in data: if 'all_day' in data:
@@ -1599,13 +1642,21 @@ class SkeenFrontendController(http.Controller):
vals['motivo_tipo'] = data['motivo_tipo'] or 'otro' vals['motivo_tipo'] = data['motivo_tipo'] or 'otro'
if 'motivo' in data: if 'motivo' in data:
vals['motivo'] = data['motivo'] or '' 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: if not vals:
return json_response({'status': 'error', 'message': 'Nada que actualizar'}, 400) 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( bloqueo.with_context(
skeen_skip_bloqueo_citas=bool(data.get('force')), skeen_skip_bloqueo_citas=bool(data.get('force')),
skeen_skip_recursos_solape=bool(data.get('force')),
).write(vals) ).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', self._auditar('agenda', 'skeen.cita.bloqueo', 'editar',
entity_label=bloqueo.doctor_id.name, entity_label=etiqueta,
new_value=str(vals)) new_value=str(vals))
return json_response({'status': 'success', 'bloqueo': self._bloqueo_to_dict(bloqueo)}) return json_response({'status': 'success', 'bloqueo': self._bloqueo_to_dict(bloqueo)})
except ValidationError as e: except ValidationError as e:
@@ -1623,7 +1674,10 @@ class SkeenFrontendController(http.Controller):
bloqueo = request.env['skeen.cita.bloqueo'].sudo().browse(bloqueo_id) bloqueo = request.env['skeen.cita.bloqueo'].sudo().browse(bloqueo_id)
if not bloqueo.exists(): if not bloqueo.exists():
return json_response({'status': 'error', 'message': 'Bloqueo no encontrado'}, 404) 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() bloqueo.unlink()
self._auditar('agenda', 'skeen.cita.bloqueo', 'eliminar', entity_label=etiqueta) self._auditar('agenda', 'skeen.cita.bloqueo', 'eliminar', entity_label=etiqueta)
return json_response({'status': 'success', 'message': 'Bloqueo eliminado'}) return json_response({'status': 'success', 'message': 'Bloqueo eliminado'})
@@ -1651,6 +1705,14 @@ class SkeenFrontendController(http.Controller):
# ============================================================ # ============================================================
# Lista de espera de pacientes # 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): def _lista_espera_to_dict(self, r):
return { return {
'id': r.id, 'id': r.id,
@@ -1666,7 +1728,7 @@ class SkeenFrontendController(http.Controller):
'priority_label': dict(r._fields['priority'].selection).get(r.priority, ''), 'priority_label': dict(r._fields['priority'].selection).get(r.priority, ''),
'franja': r.franja or '', 'franja': r.franja or '',
'sala': r.sala 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_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, 'date_to': r.date_to.strftime('%Y-%m-%d') if r.date_to else None,
'notes': r.notes or '', '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, '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', 'priority': data.get('priority') or 'normal',
'franja': data.get('franja') or '', '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_from': data.get('date_from') or False,
'date_to': data.get('date_to') or False, 'date_to': data.get('date_to') or False,
'notes': data.get('notes') or '', 'notes': data.get('notes') or '',
@@ -1793,9 +1855,9 @@ class SkeenFrontendController(http.Controller):
vals[field] = data[key] or False vals[field] = data[key] or False
if 'sala' in data: if 'sala' in data:
# Toggle de sala desde la etiqueta: '' vuelve a "Sin asignar" # 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']: 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) user=request.frontend_user.login)
if data.get('cita_id'): if data.get('cita_id'):
vals['cita_id'] = int(data['cita_id']) vals['cita_id'] = int(data['cita_id'])
@@ -1857,6 +1919,8 @@ class SkeenFrontendController(http.Controller):
'service_duration': cita.servicio_duration, 'service_duration': cita.servicio_duration,
'doctor_id': cita.doctor_id.id if cita.doctor_id else None, 'doctor_id': cita.doctor_id.id if cita.doctor_id else None,
'doctor': cita.doctor_id.name 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, 'branch': cita.branch,
'medium': cita.medium, 'medium': cita.medium,
'booking_source': cita.booking_source or 'internal', 'booking_source': cita.booking_source or 'internal',
@@ -5854,7 +5918,7 @@ class SkeenFrontendController(http.Controller):
]) ])
bloqueo_horas = {} bloqueo_horas = {}
for b in bloqueos: for b in bloqueos:
if b.date.weekday() == 6: if not b.doctor_id or b.date.weekday() == 6:
continue continue
horas = 8.0 if b.all_day else max(0.0, b.time_to - b.time_from) 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 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() request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500) 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) # Sucursales (configurables)
# ============================================================ # ============================================================

View File

@@ -283,7 +283,9 @@ class SkeenPublicBookingController(http.Controller):
if request.httprequest.method == 'OPTIONS': if request.httprequest.method == 'OPTIONS':
return _json({}, 200) return _json({}, 200)
if not self._activo(): 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() cfg = self._config()
settings = self._settings() settings = self._settings()
env = request.env env = request.env

View File

@@ -38,6 +38,10 @@ PERMISSION_GROUPS = [
('agenda.todas', 'Ver la agenda de todos (no solo la propia)'), ('agenda.todas', 'Ver la agenda de todos (no solo la propia)'),
('agenda.bloquear', 'Bloquear / cerrar agenda'), ('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', 'Pacientes', [
('pacientes.ver', 'Ver pacientes'), ('pacientes.ver', 'Ver pacientes'),
('pacientes.expediente', 'Ver expediente completo'), ('pacientes.expediente', 'Ver expediente completo'),
@@ -121,6 +125,7 @@ PERM_LABELS = {
# Permisos que sólo son lectura: son los que conserva el rol 'lectura'. # Permisos que sólo son lectura: son los que conserva el rol 'lectura'.
READ_PERM_KEYS = ( READ_PERM_KEYS = (
'agenda.ver', 'agenda.todas', 'agenda.ver', 'agenda.todas',
'recursos.ver',
'pacientes.ver', 'pacientes.expediente', 'pacientes.exportar', 'pacientes.saldos', 'pacientes.ver', 'pacientes.expediente', 'pacientes.exportar', 'pacientes.saldos',
'visitas.ver', 'visitas.ver',
'inventario.ver', 'inventario.ver',
@@ -168,6 +173,9 @@ DEFAULT_PERM_ROLES = {
'agenda.visitas': W, 'agenda.visitas': W,
'agenda.todas': W, 'agenda.todas': W,
'agenda.bloquear': 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
'pacientes.ver': ALL, 'pacientes.ver': ALL,
'pacientes.expediente': ALL, 'pacientes.expediente': ALL,