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:
@@ -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>
|
||||||
|
|||||||
423
frontend/src/components/RecursosPanel.tsx
Normal file
423
frontend/src/components/RecursosPanel.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
167
frontend/src/components/reportes/RecursosReport.tsx
Normal file
167
frontend/src/components/reportes/RecursosReport.tsx
Normal 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;
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
81
frontend/src/lib/recursos.tsx
Normal file
81
frontend/src/lib/recursos.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' ? (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
59
odoo-addons/skeen_citas/models/recurso.py
Normal file
59
odoo-addons/skeen_citas/models/recurso.py
Normal 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')]
|
||||||
@@ -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
|
||||||
|
|||||||
|
@@ -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"/>
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
63
odoo-addons/skeen_citas/views/recurso_views.xml
Normal file
63
odoo-addons/skeen_citas/views/recurso_views.xml
Normal 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>
|
||||||
@@ -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)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user