fase 2.5: formularios digitales y consentimientos (modulo skeen_formularios: plantillas con 12 tipos de campo, versionado, respuestas con firma PNG y fotos antes/despues, portal publico con token HMAC y limite de subida, historial por paciente, permisos y notificacion de envio)
This commit is contained in:
264
frontend/src/components/FormulariosPaciente.tsx
Normal file
264
frontend/src/components/FormulariosPaciente.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user