Files
SKEEN-Proyecto/frontend/src/components/RecursosPanel.tsx

424 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;