265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
import type { FC } from 'react';
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { Camera, ClipboardList, Clock, FileSignature, Send } from 'lucide-react';
|
|
import { Badge, Button, Modal, Skeleton, EmptyState, toast } from './ui';
|
|
import { odooApi, type FormularioFoto, type FormularioRespuesta } from '../services/odoo';
|
|
import EnviarFormularioModal from './formulario/EnviarFormularioModal';
|
|
import { MOMENTO_LABEL, valorLegible } from './formulario/tipos';
|
|
import { useAuth } from '../lib/auth';
|
|
import { useGlosario } from '../lib/glosario';
|
|
|
|
// Sección "Formularios" del expediente del paciente: respuestas (con quién y
|
|
// cuándo), pendientes marcados, detalle con firma y fotos, y la galería de
|
|
// fotos antes/después agrupada por fecha.
|
|
|
|
interface Props {
|
|
patientId: number;
|
|
patientName?: string;
|
|
}
|
|
|
|
const estadoVariant = (estado: string) =>
|
|
estado === 'llenado' ? 'success' : estado === 'enviado' ? 'warning' : estado === 'cancelado' ? 'danger' : 'default';
|
|
|
|
const fechaCorta = (s: string | null) => {
|
|
if (!s) return '—';
|
|
const d = new Date(`${s.slice(0, 10)}T12:00:00`);
|
|
if (Number.isNaN(d.getTime())) return s.slice(0, 10);
|
|
return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' });
|
|
};
|
|
|
|
const FormulariosPaciente: FC<Props> = ({ patientId, patientName = '' }) => {
|
|
const { can } = useAuth();
|
|
const { term } = useGlosario();
|
|
const [respuestas, setRespuestas] = useState<FormularioRespuesta[]>([]);
|
|
const [fotos, setFotos] = useState<FormularioFoto[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [detalle, setDetalle] = useState<FormularioRespuesta | null>(null);
|
|
const [enviarOpen, setEnviarOpen] = useState(false);
|
|
|
|
const cargar = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [resR, resF] = await Promise.all([
|
|
odooApi.getFormularioRespuestas({ partner_id: patientId }),
|
|
odooApi.getFormularioFotos({ partner_id: patientId }),
|
|
]);
|
|
if (resR.status === 'success') setRespuestas(resR.respuestas);
|
|
if (resF.status === 'success') setFotos(resF.fotos);
|
|
} catch {
|
|
toast.error('No se pudieron cargar los formularios');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [patientId]);
|
|
|
|
useEffect(() => { cargar(); }, [cargar]);
|
|
|
|
const pendientes = respuestas.filter((r) => r.estado === 'enviado');
|
|
|
|
const galeria = useMemo(() => {
|
|
const grupos = new Map<string, FormularioFoto[]>();
|
|
for (const f of fotos) {
|
|
const dia = (f.create_date || '').slice(0, 10) || 'sin-fecha';
|
|
if (!grupos.has(dia)) grupos.set(dia, []);
|
|
grupos.get(dia)!.push(f);
|
|
}
|
|
return Array.from(grupos.entries()).sort((a, b) => (a[0] < b[0] ? 1 : -1));
|
|
}, [fotos]);
|
|
|
|
const abrirDetalle = async (id: number) => {
|
|
try {
|
|
const res = await odooApi.getFormularioRespuesta(id);
|
|
if (res.status === 'success') setDetalle(res.respuesta);
|
|
} catch {
|
|
toast.error('No se pudo abrir el formulario');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<h4 className="flex items-center text-sm font-semibold text-theme-heading">
|
|
<FileSignature size={16} className="mr-2 text-theme-muted" /> Formularios y consentimientos
|
|
</h4>
|
|
{can('formularios.enviar') && (
|
|
<Button size="sm" variant="outline" onClick={() => setEnviarOpen(true)}>
|
|
<Send size={14} className="mr-1.5" /> Enviar formulario
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{pendientes.length > 0 && (
|
|
<div className="rounded-xl border border-amber-300 bg-amber-50 p-3">
|
|
<p className="flex items-center text-xs font-medium text-amber-800">
|
|
<Clock size={13} className="mr-1.5" /> {pendientes.length} formulario{pendientes.length > 1 ? 's' : ''} pendiente{pendientes.length > 1 ? 's' : ''} de llenar
|
|
</p>
|
|
<ul className="mt-1.5 space-y-1">
|
|
{pendientes.map((r) => (
|
|
<li key={r.id} className="text-xs text-amber-800">
|
|
{r.plantilla} · enviado {fechaCorta(r.enviado_at || r.create_date)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<Skeleton count={3} className="h-12 w-full" />
|
|
) : respuestas.length === 0 ? (
|
|
<EmptyState
|
|
title="Sin formularios"
|
|
subtitle={`Este ${term('patient_singular').toLowerCase()} aún no tiene formularios ni consentimientos.`}
|
|
icon={<ClipboardList size={26} />}
|
|
/>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{respuestas.map((r) => (
|
|
<li key={r.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => abrirDetalle(r.id)}
|
|
className={`w-full rounded-xl border p-3 text-left transition hover:shadow-card ${
|
|
r.estado === 'enviado' ? 'border-amber-300 bg-amber-50/50' : 'border-theme-border'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="text-sm font-medium text-theme-heading">{r.plantilla}</p>
|
|
<Badge variant={estadoVariant(r.estado)}>{r.estado_label || r.estado}</Badge>
|
|
</div>
|
|
<p className="mt-1 text-xs text-theme-muted">
|
|
{r.name} · {fechaCorta(r.llenado_at || r.enviado_at || r.create_date)}
|
|
{' · '}
|
|
{r.creado_por_empleado ? 'Llenado por el personal' : `Llenado por el ${term('patient_singular').toLowerCase()}`}
|
|
{r.tiene_firma ? ' · firmado' : ''}
|
|
{r.fotos_count > 0 ? ` · ${r.fotos_count} foto${r.fotos_count > 1 ? 's' : ''}` : ''}
|
|
</p>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{galeria.length > 0 && (
|
|
<section>
|
|
<h4 className="mb-2 flex items-center text-sm font-semibold text-theme-heading">
|
|
<Camera size={16} className="mr-2 text-theme-muted" /> Galería antes / después
|
|
</h4>
|
|
<div className="space-y-4">
|
|
{galeria.map(([dia, items]) => (
|
|
<div key={dia}>
|
|
<p className="mb-1.5 text-xs font-medium text-theme-muted">{fechaCorta(dia)}</p>
|
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
|
{items.map((f) => (
|
|
<figure key={f.id} className="relative">
|
|
<img
|
|
src={odooApi.formularioArchivoUrl(f.url)}
|
|
alt={f.name}
|
|
className="h-28 w-full rounded-xl border border-theme-border object-cover"
|
|
loading="lazy"
|
|
/>
|
|
<figcaption className="absolute bottom-1 left-1 rounded-md bg-theme-surface/90 px-1.5 py-0.5 text-[10px] font-medium text-theme-heading">
|
|
{MOMENTO_LABEL[f.kind] || f.kind}
|
|
</figcaption>
|
|
</figure>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<Modal
|
|
isOpen={!!detalle}
|
|
onClose={() => setDetalle(null)}
|
|
title={detalle ? `${detalle.plantilla} — ${detalle.name}` : 'Formulario'}
|
|
maxWidth="2xl"
|
|
>
|
|
{detalle && (
|
|
<div className="max-h-[70vh] space-y-4 overflow-y-auto pr-1">
|
|
<div className="flex flex-wrap items-center gap-2 text-sm text-theme-muted">
|
|
<Badge variant={estadoVariant(detalle.estado)}>{detalle.estado_label || detalle.estado}</Badge>
|
|
<span>{fechaCorta(detalle.llenado_at || detalle.enviado_at || detalle.create_date)}</span>
|
|
<span>· {detalle.creado_por_empleado ? 'Llenado por el personal' : `Llenado por el ${term('patient_singular').toLowerCase()}`}</span>
|
|
</div>
|
|
|
|
{(detalle.campos || []).length > 0 && (
|
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
{(detalle.campos || [])
|
|
.filter((c) => !['firma', 'foto', 'archivo'].includes(c.tipo))
|
|
.map((c) => (
|
|
<div key={c.clave} className="rounded-xl bg-theme-bg p-3">
|
|
<p className="text-xs text-theme-muted">{c.etiqueta}</p>
|
|
<p className="text-sm text-theme-heading whitespace-pre-wrap">
|
|
{valorLegible(c, detalle.respuestas?.[c.clave])}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{detalle.texto_legal && (
|
|
<details className="rounded-xl bg-theme-bg p-3">
|
|
<summary className="cursor-pointer text-xs font-medium text-theme-muted">Texto legal aceptado</summary>
|
|
<p className="mt-2 text-xs leading-relaxed text-theme-muted whitespace-pre-wrap">{detalle.texto_legal}</p>
|
|
</details>
|
|
)}
|
|
|
|
{detalle.tiene_firma && detalle.firma_url && (
|
|
<div>
|
|
<p className="mb-1 text-xs text-theme-muted">Firma{detalle.firma_nombre ? ` de ${detalle.firma_nombre}` : ''}</p>
|
|
<img
|
|
src={odooApi.formularioArchivoUrl(detalle.firma_url)}
|
|
alt="Firma"
|
|
className="max-h-40 rounded-xl border border-theme-border bg-white p-2"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{(detalle.fotos || []).length > 0 && (
|
|
<div>
|
|
<p className="mb-1 text-xs text-theme-muted">Fotos y archivos</p>
|
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
{(detalle.fotos || []).map((f) => (
|
|
<figure key={f.id} className="relative">
|
|
{f.mimetype.startsWith('image/') ? (
|
|
<img
|
|
src={odooApi.formularioArchivoUrl(f.url)}
|
|
alt={f.name}
|
|
className="h-32 w-full rounded-xl border border-theme-border object-cover"
|
|
/>
|
|
) : (
|
|
<a
|
|
href={odooApi.formularioArchivoUrl(f.url)}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="flex h-32 items-center justify-center rounded-xl border border-theme-border bg-theme-bg px-2 text-center text-xs text-theme-heading"
|
|
>
|
|
{f.name}
|
|
</a>
|
|
)}
|
|
<figcaption className="absolute bottom-1 left-1 rounded-md bg-theme-surface/90 px-1.5 py-0.5 text-[10px] font-medium text-theme-heading">
|
|
{MOMENTO_LABEL[f.kind] || f.kind}
|
|
</figcaption>
|
|
</figure>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
|
|
<EnviarFormularioModal
|
|
isOpen={enviarOpen}
|
|
onClose={() => setEnviarOpen(false)}
|
|
patientId={patientId}
|
|
patientName={patientName}
|
|
onSent={cargar}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default FormulariosPaciente;
|