424 lines
17 KiB
TypeScript
424 lines
17 KiB
TypeScript
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;
|