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

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;