616 lines
24 KiB
TypeScript
616 lines
24 KiB
TypeScript
import type { FC } from 'react';
|
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
import { ClipboardList, CheckCircle2, XCircle, X, Plus, Camera, FileText } from 'lucide-react';
|
|
import Layout from '../components/Layout';
|
|
import { RecetaSection, RecetaPrint } from '../components/RecetaPrint';
|
|
import {
|
|
Card,
|
|
Button,
|
|
Input,
|
|
Select,
|
|
TextArea,
|
|
Modal,
|
|
Badge,
|
|
EmptyState,
|
|
MobileCard,
|
|
PageHeader,
|
|
Skeleton,
|
|
toast,
|
|
} from '../components/ui';
|
|
import { odooApi, type Visita, type VisitaAdjunto, type Doctor, type InventoryItem } from '../services/odoo';
|
|
import type { BadgeVariant } from '../components/ui/Badge';
|
|
import { todayLocal } from '../lib/utils';
|
|
|
|
const visitaStateLabels: Record<string, string> = {
|
|
en_curso: 'En curso',
|
|
completada: 'Completada',
|
|
cancelada: 'Cancelada',
|
|
};
|
|
|
|
const badgeForVisitaState = (state: string): BadgeVariant =>
|
|
state === 'en_curso' ? 'info' : state === 'completada' ? 'success' : state === 'cancelada' ? 'danger' : 'default';
|
|
|
|
const estadoOptions = [
|
|
{ value: '', label: 'Todos' },
|
|
{ value: 'en_curso', label: 'En curso' },
|
|
{ value: 'completada', label: 'Completada' },
|
|
{ value: 'cancelada', label: 'Cancelada' },
|
|
];
|
|
|
|
const adjuntoKindLabels: Record<string, string> = {
|
|
antes: 'Antes',
|
|
despues: 'Después',
|
|
documento: 'Documento',
|
|
};
|
|
|
|
const MAX_ADJUNTO_BYTES = 10 * 1024 * 1024;
|
|
|
|
const hoy = () => todayLocal();
|
|
|
|
const Visitas: FC = () => {
|
|
const [visitas, setVisitas] = useState<Visita[]>([]);
|
|
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [fecha, setFecha] = useState(hoy());
|
|
const [estado, setEstado] = useState('');
|
|
const [doctorFiltro, setDoctorFiltro] = useState('');
|
|
const [selected, setSelected] = useState<Visita | null>(null);
|
|
const [form, setForm] = useState({
|
|
motivo: '',
|
|
diagnostico: '',
|
|
tratamiento: '',
|
|
notas: '',
|
|
receta_text: '',
|
|
doctor_id: '',
|
|
cosmetologa_id: '',
|
|
});
|
|
const [printOpen, setPrintOpen] = useState(false);
|
|
const [items, setItems] = useState<InventoryItem[]>([]);
|
|
const [itemSearch, setItemSearch] = useState('');
|
|
const [nuevoItem, setNuevoItem] = useState('');
|
|
const [nuevaQty, setNuevaQty] = useState('1');
|
|
const [visor, setVisor] = useState<VisitaAdjunto | null>(null);
|
|
const fileAntesRef = useRef<HTMLInputElement>(null);
|
|
const fileDespuesRef = useRef<HTMLInputElement>(null);
|
|
const fileDocRef = useRef<HTMLInputElement>(null);
|
|
|
|
const doctorOptions = [
|
|
{ value: '', label: 'Sin asignar' },
|
|
...doctors.map((d) => ({ value: String(d.id), label: d.name })),
|
|
];
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const params: Record<string, string | number> = {};
|
|
if (fecha) params.date = fecha;
|
|
if (estado) params.state = estado;
|
|
if (doctorFiltro) params.doctor_id = doctorFiltro;
|
|
const res = await odooApi.getVisitas(params);
|
|
if (res.status === 'success') setVisitas(res.visitas);
|
|
} catch (err) {
|
|
toast.error('Error al cargar visitas');
|
|
console.error(err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [fecha, estado, doctorFiltro]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
useEffect(() => {
|
|
odooApi.getDoctors()
|
|
.then((res) => {
|
|
if (res.status === 'success') setDoctors(res.doctors);
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
// Catálogo de artículos del inventario para el picker de insumos
|
|
useEffect(() => {
|
|
if (!selected || selected.state !== 'en_curso') return;
|
|
odooApi.getInventarioItems(itemSearch || undefined)
|
|
.then((res) => {
|
|
if (res.status === 'success') setItems(res.items);
|
|
})
|
|
.catch(() => {});
|
|
}, [selected, itemSearch]);
|
|
|
|
const itemOptions = [
|
|
{ value: '', label: 'Selecciona artículo...' },
|
|
...items.map((it) => ({ value: String(it.id), label: `${it.name} (${it.qty} ${it.unit})` })),
|
|
];
|
|
|
|
const agregarInsumo = async () => {
|
|
if (!selected || !nuevoItem) return;
|
|
const qty = parseFloat(nuevaQty);
|
|
if (Number.isNaN(qty) || qty <= 0) {
|
|
toast.error('La cantidad debe ser mayor a 0');
|
|
return;
|
|
}
|
|
try {
|
|
setSubmitting(true);
|
|
const res = await odooApi.addVisitaInsumo(selected.id, { item_id: parseInt(nuevoItem, 10), qty });
|
|
if (res.status === 'success') {
|
|
applyUpdate(res.visita);
|
|
setNuevoItem('');
|
|
setNuevaQty('1');
|
|
toast.success('Insumo agregado');
|
|
}
|
|
} catch (err) {
|
|
toast.error('Error al agregar insumo');
|
|
console.error(err);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const quitarInsumo = async (lineId: number) => {
|
|
if (!selected) return;
|
|
try {
|
|
setSubmitting(true);
|
|
const res = await odooApi.deleteVisitaInsumo(selected.id, lineId);
|
|
if (res.status === 'success') applyUpdate(res.visita);
|
|
} catch (err) {
|
|
toast.error('Error al quitar insumo');
|
|
console.error(err);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const subirAdjunto = (kind: 'antes' | 'despues' | 'documento') => (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
e.target.value = '';
|
|
if (!file || !selected) return;
|
|
if (file.size > MAX_ADJUNTO_BYTES) {
|
|
toast.error('El archivo excede 10 MB');
|
|
return;
|
|
}
|
|
const visitaId = selected.id;
|
|
const reader = new FileReader();
|
|
reader.onload = async () => {
|
|
const file_b64 = String(reader.result || '').split(',')[1] || '';
|
|
try {
|
|
setSubmitting(true);
|
|
const res = await odooApi.uploadVisitaAdjunto(visitaId, {
|
|
kind,
|
|
name: file.name,
|
|
file_b64,
|
|
mimetype: file.type || 'application/octet-stream',
|
|
});
|
|
if (res.status === 'success') {
|
|
applyUpdate(res.visita);
|
|
toast.success('Archivo subido');
|
|
}
|
|
} catch (err) {
|
|
toast.error('Error al subir el archivo');
|
|
console.error(err);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
reader.readAsDataURL(file);
|
|
};
|
|
|
|
const borrarAdjunto = async (adjId: number) => {
|
|
if (!window.confirm('¿Eliminar este archivo?')) return;
|
|
try {
|
|
setSubmitting(true);
|
|
const res = await odooApi.deleteVisitaAdjunto(adjId);
|
|
if (res.status === 'success') applyUpdate(res.visita);
|
|
} catch (err) {
|
|
toast.error('Error al eliminar el archivo');
|
|
console.error(err);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const openDetail = (v: Visita) => {
|
|
setSelected(v);
|
|
setForm({
|
|
motivo: v.motivo || '',
|
|
diagnostico: v.diagnostico || '',
|
|
tratamiento: v.tratamiento || '',
|
|
notas: v.notas || '',
|
|
receta_text: v.receta_text || '',
|
|
doctor_id: v.doctor_id ? String(v.doctor_id) : '',
|
|
cosmetologa_id: v.cosmetologa_id ? String(v.cosmetologa_id) : '',
|
|
});
|
|
};
|
|
|
|
const updateForm = (patch: Partial<typeof form>) => {
|
|
setForm((prev) => ({ ...prev, ...patch }));
|
|
};
|
|
|
|
const applyUpdate = (v: Visita) => {
|
|
setSelected(v);
|
|
setVisitas((prev) => prev.map((it) => (it.id === v.id ? v : it)));
|
|
};
|
|
|
|
const save = async () => {
|
|
if (!selected) return;
|
|
try {
|
|
setSubmitting(true);
|
|
const res = await odooApi.updateVisita(selected.id, {
|
|
motivo: form.motivo,
|
|
diagnostico: form.diagnostico,
|
|
tratamiento: form.tratamiento,
|
|
notas: form.notas,
|
|
receta_text: form.receta_text,
|
|
doctor_id: form.doctor_id ? parseInt(form.doctor_id, 10) : null,
|
|
cosmetologa_id: form.cosmetologa_id ? parseInt(form.cosmetologa_id, 10) : null,
|
|
});
|
|
if (res.status === 'success') {
|
|
applyUpdate(res.visita);
|
|
toast.success('Visita actualizada');
|
|
}
|
|
} catch (err) {
|
|
toast.error('Error al guardar la visita');
|
|
console.error(err);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const completar = async () => {
|
|
if (!selected) return;
|
|
try {
|
|
setSubmitting(true);
|
|
const res = await odooApi.completeVisita(selected.id);
|
|
if (res.status === 'success') {
|
|
applyUpdate(res.visita);
|
|
toast.success('Visita completada');
|
|
}
|
|
} catch (err) {
|
|
toast.error('Error al completar la visita');
|
|
console.error(err);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const cancelar = async () => {
|
|
if (!selected) return;
|
|
try {
|
|
setSubmitting(true);
|
|
const res = await odooApi.cancelVisita(selected.id);
|
|
if (res.status === 'success') {
|
|
applyUpdate(res.visita);
|
|
toast.success('Visita cancelada');
|
|
}
|
|
} catch (err) {
|
|
toast.error('Error al cancelar la visita');
|
|
console.error(err);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const hora = (v: Visita) => (v.date_start ? v.date_start.split(' ')[1] || '' : '-');
|
|
|
|
return (
|
|
<Layout title="Visitas" subtitle="Registro de visitas clínicas">
|
|
<PageHeader title="Visitas" subtitle="Consulta y completa las visitas clínicas del día" />
|
|
|
|
<Card>
|
|
<Card.Body>
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4 sm:mb-6">
|
|
<Input label="Fecha" type="date" value={fecha} onChange={(e) => setFecha(e.target.value)} />
|
|
<Select label="Estado" options={estadoOptions} value={estado} onChange={(e) => setEstado(e.target.value)} />
|
|
<Select
|
|
label="Médico"
|
|
options={[{ value: '', label: 'Todos' }, ...doctorOptions.slice(1)]}
|
|
value={doctorFiltro}
|
|
onChange={(e) => setDoctorFiltro(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<Skeleton count={6} className="h-12 w-full" />
|
|
) : visitas.length === 0 ? (
|
|
<EmptyState
|
|
title={fecha === hoy() ? 'Sin visitas hoy' : 'Sin visitas'}
|
|
subtitle="No se encontraron visitas con los filtros seleccionados."
|
|
icon={<ClipboardList size={28} />}
|
|
/>
|
|
) : (
|
|
<>
|
|
<div className="hidden sm:block 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">Hora</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Paciente</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Servicio</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Médico</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden lg:table-cell">Motivo</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{visitas.map((v) => (
|
|
<tr key={v.id} className="hover:bg-theme-bg cursor-pointer" onClick={() => openDetail(v)}>
|
|
<td className="p-3 text-sm text-theme-muted">{hora(v)}</td>
|
|
<td className="p-3 text-sm font-medium text-theme-heading">{v.patient}</td>
|
|
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{v.servicio || '-'}</td>
|
|
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{v.doctor || '-'}</td>
|
|
<td className="p-3 text-sm text-theme-muted hidden lg:table-cell truncate max-w-[200px]">{v.motivo || '-'}</td>
|
|
<td className="p-3 text-sm">
|
|
<Badge variant={badgeForVisitaState(v.state)}>
|
|
{visitaStateLabels[v.state] || v.state}
|
|
</Badge>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="sm:hidden space-y-3">
|
|
{visitas.map((v) => (
|
|
<MobileCard
|
|
key={v.id}
|
|
title={v.patient}
|
|
subtitle={hora(v)}
|
|
rows={[
|
|
{ label: 'Servicio', value: v.servicio || '-' },
|
|
{ label: 'Médico', value: v.doctor || '-' },
|
|
{
|
|
label: 'Estado',
|
|
value: (
|
|
<Badge variant={badgeForVisitaState(v.state)}>
|
|
{visitaStateLabels[v.state] || v.state}
|
|
</Badge>
|
|
),
|
|
},
|
|
]}
|
|
onClick={() => openDetail(v)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</Card.Body>
|
|
</Card>
|
|
|
|
{/* Modal detalle/edición */}
|
|
<Modal
|
|
isOpen={!!selected}
|
|
onClose={() => setSelected(null)}
|
|
title={selected ? `Visita ${selected.name} — ${selected.patient}` : 'Visita'}
|
|
maxWidth="2xl"
|
|
footer={
|
|
<>
|
|
<Button variant="outline" onClick={() => setSelected(null)}>Cerrar</Button>
|
|
{selected?.state === 'en_curso' && (
|
|
<Button variant="outline" onClick={cancelar} loading={submitting}>
|
|
<XCircle size={16} className="mr-2" />
|
|
Cancelar visita
|
|
</Button>
|
|
)}
|
|
<Button onClick={save} loading={submitting}>Guardar</Button>
|
|
{selected?.state === 'en_curso' && (
|
|
<Button onClick={completar} loading={submitting}>
|
|
<CheckCircle2 size={16} className="mr-2" />
|
|
Completar visita
|
|
</Button>
|
|
)}
|
|
</>
|
|
}
|
|
>
|
|
{selected && (
|
|
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge variant={badgeForVisitaState(selected.state)}>
|
|
{visitaStateLabels[selected.state] || selected.state}
|
|
</Badge>
|
|
<span className="text-sm text-theme-muted">
|
|
{selected.date_start || '-'}{selected.date_end ? ` → ${selected.date_end}` : ''}
|
|
</span>
|
|
{selected.servicio && (
|
|
<span className="text-sm text-theme-muted">· {selected.servicio}</span>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<Select
|
|
label="Médico"
|
|
options={doctorOptions}
|
|
value={form.doctor_id}
|
|
onChange={(e) => updateForm({ doctor_id: e.target.value })}
|
|
/>
|
|
<Select
|
|
label="Cosmetóloga"
|
|
options={doctorOptions}
|
|
value={form.cosmetologa_id}
|
|
onChange={(e) => updateForm({ cosmetologa_id: e.target.value })}
|
|
/>
|
|
</div>
|
|
<Input label="Motivo de consulta" value={form.motivo} onChange={(e) => updateForm({ motivo: e.target.value })} />
|
|
<TextArea label="Diagnóstico" value={form.diagnostico} onChange={(e) => updateForm({ diagnostico: e.target.value })} />
|
|
<TextArea label="Tratamiento" value={form.tratamiento} onChange={(e) => updateForm({ tratamiento: e.target.value })} />
|
|
<TextArea label="Notas" value={form.notas} onChange={(e) => updateForm({ notas: e.target.value })} />
|
|
|
|
<RecetaSection
|
|
value={form.receta_text}
|
|
onChange={(v) => updateForm({ receta_text: v })}
|
|
onPrint={() => setPrintOpen(true)}
|
|
/>
|
|
|
|
{/* Insumos usados */}
|
|
<section className="border-t border-theme-border pt-4">
|
|
<h4 className="text-sm font-semibold text-theme-heading mb-3">Insumos usados</h4>
|
|
{selected.insumos_descargados && (
|
|
<p className="text-xs text-theme-muted mb-2">Insumos descargados del inventario.</p>
|
|
)}
|
|
{selected.insumos.length === 0 ? (
|
|
<p className="text-sm text-theme-muted mb-3">Sin insumos registrados.</p>
|
|
) : (
|
|
<ul className="space-y-1 mb-3">
|
|
{selected.insumos.map((l) => (
|
|
<li key={l.id} className="flex items-center justify-between p-2 bg-theme-bg rounded-lg text-sm">
|
|
<span className="text-theme-heading">
|
|
{l.item}
|
|
{l.notes && <span className="block text-xs text-theme-muted">{l.notes}</span>}
|
|
</span>
|
|
<span className="flex items-center gap-2 shrink-0">
|
|
<span className="text-theme-muted">{l.qty} {l.unit}</span>
|
|
{selected.state === 'en_curso' && (
|
|
<Button variant="ghost" size="sm" onClick={() => quitarInsumo(l.id)} title="Quitar" disabled={submitting}>
|
|
<X size={14} className="text-theme-muted" />
|
|
</Button>
|
|
)}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{selected.state === 'en_curso' && (
|
|
<div className="space-y-2">
|
|
<Input
|
|
placeholder="Buscar artículo..."
|
|
value={itemSearch}
|
|
onChange={(e) => setItemSearch(e.target.value)}
|
|
/>
|
|
<div className="flex items-end gap-2">
|
|
<div className="flex-1">
|
|
<Select
|
|
options={itemOptions}
|
|
value={nuevoItem}
|
|
onChange={(e) => setNuevoItem(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="w-24">
|
|
<Input
|
|
type="number"
|
|
min="0.5"
|
|
step="0.5"
|
|
value={nuevaQty}
|
|
onChange={(e) => setNuevaQty(e.target.value)}
|
|
/>
|
|
</div>
|
|
<Button onClick={agregarInsumo} disabled={!nuevoItem} loading={submitting}>
|
|
<Plus size={16} className="mr-1" />
|
|
Agregar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Fotos y documentos */}
|
|
<section className="border-t border-theme-border pt-4">
|
|
<h4 className="text-sm font-semibold text-theme-heading mb-3">Fotos y documentos</h4>
|
|
{selected.state === 'en_curso' && (
|
|
<div className="flex flex-wrap gap-2 mb-3">
|
|
<input ref={fileAntesRef} type="file" accept="image/*" className="hidden" onChange={subirAdjunto('antes')} />
|
|
<input ref={fileDespuesRef} type="file" accept="image/*" className="hidden" onChange={subirAdjunto('despues')} />
|
|
<input ref={fileDocRef} type="file" className="hidden" onChange={subirAdjunto('documento')} />
|
|
<Button variant="outline" size="sm" onClick={() => fileAntesRef.current?.click()} disabled={submitting}>
|
|
<Camera size={14} className="mr-1.5" /> Foto antes
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={() => fileDespuesRef.current?.click()} disabled={submitting}>
|
|
<Camera size={14} className="mr-1.5" /> Foto después
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={() => fileDocRef.current?.click()} disabled={submitting}>
|
|
<FileText size={14} className="mr-1.5" /> Documento
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{selected.adjuntos.length === 0 ? (
|
|
<p className="text-sm text-theme-muted">Sin archivos adjuntos.</p>
|
|
) : (
|
|
<>
|
|
{selected.adjuntos.filter((a) => a.kind !== 'documento').length > 0 && (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-3">
|
|
{selected.adjuntos.filter((a) => a.kind !== 'documento').map((a) => (
|
|
<div key={a.id} className="relative">
|
|
<button type="button" onClick={() => setVisor(a)} className="w-full">
|
|
<img
|
|
src={odooApi.visitaAdjuntoUrl(a.url)}
|
|
alt={a.name}
|
|
className="w-full h-24 object-cover rounded-xl border border-theme-border"
|
|
/>
|
|
</button>
|
|
<span className="absolute bottom-1 left-1 px-1.5 py-0.5 rounded-md text-[10px] font-medium bg-theme-surface/90 text-theme-heading">
|
|
{adjuntoKindLabels[a.kind] || a.kind}
|
|
</span>
|
|
{selected.state === 'en_curso' && (
|
|
<button
|
|
type="button"
|
|
onClick={() => borrarAdjunto(a.id)}
|
|
title="Eliminar"
|
|
className="absolute top-1 right-1 p-1 rounded-full bg-theme-surface/90 text-theme-muted hover:text-rose-600"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{selected.adjuntos.filter((a) => a.kind === 'documento').length > 0 && (
|
|
<ul className="space-y-1">
|
|
{selected.adjuntos.filter((a) => a.kind === 'documento').map((a) => (
|
|
<li key={a.id} className="flex items-center justify-between p-2 bg-theme-bg rounded-lg text-sm">
|
|
<a
|
|
href={odooApi.visitaAdjuntoUrl(a.url)}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="flex items-center text-theme-heading hover:underline min-w-0"
|
|
>
|
|
<FileText size={14} className="mr-1.5 text-theme-muted shrink-0" />
|
|
<span className="truncate">{a.name}</span>
|
|
</a>
|
|
{selected.state === 'en_curso' && (
|
|
<Button variant="ghost" size="sm" onClick={() => borrarAdjunto(a.id)} title="Eliminar" disabled={submitting}>
|
|
<X size={14} className="text-theme-muted" />
|
|
</Button>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</>
|
|
)}
|
|
</section>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* Visor de foto */}
|
|
<Modal
|
|
isOpen={!!visor}
|
|
onClose={() => setVisor(null)}
|
|
title={visor ? `${adjuntoKindLabels[visor.kind] || visor.kind} — ${visor.name}` : 'Foto'}
|
|
maxWidth="2xl"
|
|
>
|
|
{visor && (
|
|
<img
|
|
src={odooApi.visitaAdjuntoUrl(visor.url)}
|
|
alt={visor.name}
|
|
className="w-full rounded-xl"
|
|
/>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* Vista de impresión de la receta */}
|
|
{selected && (
|
|
<RecetaPrint
|
|
isOpen={printOpen}
|
|
onClose={() => setPrintOpen(false)}
|
|
paciente={selected.patient}
|
|
doctor={selected.doctor}
|
|
contenido={form.receta_text}
|
|
/>
|
|
)}
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default Visitas;
|