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:
2026-10-11 09:48:28 +00:00
parent 91f909a6d0
commit 9286ab8ee2
30 changed files with 3545 additions and 12 deletions

View File

@@ -35,6 +35,7 @@ import MisVentas from './pages/MisVentas';
import Login from './pages/Login';
import Reservar from './pages/Reservar';
import ReservarCita from './pages/ReservarCita';
import FormularioPublico from './pages/FormularioPublico';
import './App.css';
const FullScreenLoader: FC = () => (
@@ -140,6 +141,8 @@ const App: FC = () => {
{/* Portal público de reservas: FUERA del gate de autenticación. */}
<Route path="/reservar" element={<Reservar />} />
<Route path="/reservar/cita/:token" element={<ReservarCita />} />
{/* Portal público de formularios: token HMAC como credencial. */}
<Route path="/formulario/:token" element={<FormularioPublico />} />
<Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} />
<Route path="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
<Route path="/mis-ventas" element={<RequireAuth href="/mis-ventas"><MisVentas /></RequireAuth>} />

View 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;

View File

@@ -0,0 +1,438 @@
import type { FC } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ChevronDown, ChevronUp, Eye, FileSignature, GripVertical, Plus, Save, Send, Trash2,
} from 'lucide-react';
import { Badge, Button, Card, EmptyState, Input, Modal, Select, Skeleton, TextArea, toast } from './ui';
import { odooApi, type FormularioCampo, type FormularioCampoTipo, type FormularioPlantilla } from '../services/odoo';
import FormularioCampos from './formulario/FormularioCampos';
import EnviarFormularioModal from './formulario/EnviarFormularioModal';
import { CAMPO_TIPOS, PLANTILLA_TIPOS, TIPO_LABEL, MOMENTO_LABEL } from './formulario/tipos';
import { useAuth } from '../lib/auth';
import { useGlosario } from '../lib/glosario';
// Configuración › Formularios: constructor de plantillas (campos, texto legal,
// firma/foto), vista previa tal como la verá el paciente y envío a un paciente.
// Sin plantillas no hay nada que mostrar más allá del estado vacío.
const slug = (texto: string): string =>
texto
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 40);
const campoNuevo = (campos: FormularioCampo[], tipo: FormularioCampoTipo = 'texto'): FormularioCampo => {
const base = 'campo';
let n = campos.length + 1;
const claves = new Set(campos.map((c) => c.clave));
while (claves.has(`${base}_${n}`)) n += 1;
return { clave: `${base}_${n}`, etiqueta: '', tipo, obligatorio: false, ayuda: '', opciones: [] };
};
const FormulariosPanel: FC = () => {
const { can } = useAuth();
const { term } = useGlosario();
const [plantillas, setPlantillas] = useState<FormularioPlantilla[]>([]);
const [loading, setLoading] = useState(true);
const [editando, setEditando] = useState<FormularioPlantilla | 'nueva' | null>(null);
const [enviarOpen, setEnviarOpen] = useState(false);
const cargar = useCallback(async () => {
setLoading(true);
try {
const res = await odooApi.getFormularioPlantillas({ includeInactive: true });
if (res.status === 'success') setPlantillas(res.plantillas);
} catch {
toast.error('No se pudieron cargar las plantillas');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { cargar(); }, [cargar]);
const toggleActivo = async (p: FormularioPlantilla) => {
try {
await odooApi.updateFormularioPlantilla(p.id, { activo: !p.activo });
setPlantillas((prev) => prev.map((it) => (it.id === p.id ? { ...it, activo: !it.activo } : it)));
} catch {
toast.error('No se pudo cambiar el estado');
}
};
const eliminar = async (p: FormularioPlantilla) => {
if (!window.confirm(`¿Eliminar la plantilla "${p.name}"?`)) return;
try {
const res = await odooApi.deleteFormularioPlantilla(p.id);
toast.success(res.message || 'Plantilla eliminada');
cargar();
} catch {
toast.error('No se pudo eliminar la plantilla');
}
};
return (
<div className="space-y-5">
<div className="flex items-start gap-3">
<FileSignature size={20} className="mt-0.5 shrink-0 text-theme-accent" />
<div className="min-w-0 flex-1">
<h3 className="font-heading text-xl text-theme-heading">Formularios digitales</h3>
<p className="text-sm text-theme-muted">
Consentimientos, autorizaciones, encuestas y notas que el {term('patient_singular').toLowerCase()} llena desde su celular
con firma y fotos, o que el personal llena en recepción. Mientras no existan plantillas,
no aparece nada nuevo para el {term('patient_singular').toLowerCase()}.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
{can('formularios.enviar') && (
<Button variant="outline" onClick={() => setEnviarOpen(true)}>
<Send size={15} className="mr-1.5" /> Enviar
</Button>
)}
{can('formularios.crear') && (
<Button onClick={() => setEditando('nueva')}>
<Plus size={15} className="mr-1.5" /> Nueva plantilla
</Button>
)}
</div>
</div>
{loading ? (
<Skeleton count={3} className="h-16 w-full" />
) : plantillas.length === 0 ? (
<Card>
<Card.Body>
<EmptyState
title="Aún no hay plantillas"
subtitle="Crea la primera plantilla de consentimiento o formulario. Hasta entonces no cambia nada en el sistema."
icon={<FileSignature size={28} />}
actionLabel={can('formularios.crear') ? 'Nueva plantilla' : undefined}
onAction={can('formularios.crear') ? () => setEditando('nueva') : undefined}
/>
</Card.Body>
</Card>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="p-3 text-left text-xs font-medium uppercase text-theme-muted">Plantilla</th>
<th className="p-3 text-left text-xs font-medium uppercase text-theme-muted">Tipo</th>
<th className="hidden p-3 text-left text-xs font-medium uppercase text-theme-muted sm:table-cell">Campos</th>
<th className="p-3 text-left text-xs font-medium uppercase text-theme-muted">Estado</th>
<th className="p-3 text-right text-xs font-medium uppercase text-theme-muted">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{plantillas.map((p) => (
<tr key={p.id} className="hover:bg-theme-bg">
<td className="p-3">
<p className="text-sm font-medium text-theme-heading">{p.name}</p>
<p className="text-xs text-theme-muted">v{p.version} · {p.respuestas_count} respuesta{p.respuestas_count === 1 ? '' : 's'}</p>
</td>
<td className="p-3 text-sm text-theme-muted">{p.tipo_label || p.tipo}</td>
<td className="hidden p-3 text-sm text-theme-muted sm:table-cell">{p.campos.length}</td>
<td className="p-3">
<Badge variant={p.activo ? 'success' : 'default'}>{p.activo ? 'Activa' : 'Inactiva'}</Badge>
</td>
<td className="p-3">
<div className="flex flex-wrap justify-end gap-1">
{can('formularios.editar') && (
<>
<Button size="sm" variant="ghost" onClick={() => setEditando(p)}>Editar</Button>
<Button size="sm" variant="ghost" onClick={() => toggleActivo(p)}>
{p.activo ? 'Desactivar' : 'Activar'}
</Button>
<Button size="sm" variant="ghost" onClick={() => eliminar(p)} title="Eliminar">
<Trash2 size={14} className="text-rose-500" />
</Button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{editando && (
<EditorPlantilla
plantilla={editando === 'nueva' ? null : editando}
canSave={can('formularios.crear') || can('formularios.editar')}
onClose={() => setEditando(null)}
onSaved={() => { setEditando(null); cargar(); }}
/>
)}
<EnviarFormularioModal isOpen={enviarOpen} onClose={() => setEnviarOpen(false)} onSent={cargar} />
</div>
);
};
// ---------------------------------------------------------------------------
const EditorPlantilla: FC<{
plantilla: FormularioPlantilla | null;
canSave: boolean;
onClose: () => void;
onSaved: () => void;
}> = ({ plantilla, canSave, onClose, onSaved }) => {
const { term } = useGlosario();
const [name, setName] = useState(plantilla?.name || '');
const [tipo, setTipo] = useState(plantilla?.tipo || 'consentimiento');
const [descripcion, setDescripcion] = useState(plantilla?.descripcion || '');
const [duracion, setDuracion] = useState(plantilla?.duracion_estimada_min ?? 5);
const [requiereFirma, setRequiereFirma] = useState(plantilla?.requiere_firma ?? false);
const [requiereFoto, setRequiereFoto] = useState(plantilla?.requiere_foto ?? false);
const [textoLegal, setTextoLegal] = useState(plantilla?.texto_legal || '');
const [campos, setCampos] = useState<FormularioCampo[]>(plantilla?.campos || []);
const [guardando, setGuardando] = useState(false);
const [preview, setPreview] = useState(false);
const previewValores = useMemo(() => ({}), []);
const actualizar = (i: number, patch: Partial<FormularioCampo>) =>
setCampos((prev) => prev.map((c, j) => (j === i ? { ...c, ...patch } : c)));
const mover = (i: number, dir: -1 | 1) =>
setCampos((prev) => {
const j = i + dir;
if (j < 0 || j >= prev.length) return prev;
const copia = [...prev];
[copia[i], copia[j]] = [copia[j], copia[i]];
return copia;
});
const quitar = (i: number) => setCampos((prev) => prev.filter((_, j) => j !== i));
const agregar = (t: FormularioCampoTipo) => setCampos((prev) => [...prev, campoNuevo(prev, t)]);
const guardar = async () => {
if (!name.trim()) { toast.error('Ponle un nombre a la plantilla'); return; }
if (campos.some((c) => !c.clave.trim())) { toast.error('Cada campo necesita una clave'); return; }
const claves = campos.map((c) => c.clave.trim());
if (new Set(claves).size !== claves.length) { toast.error('Hay claves repetidas'); return; }
if (campos.some((c) => (c.tipo === 'seleccion' || c.tipo === 'multi_seleccion') && c.opciones.length === 0)) {
toast.error('Los campos de opciones necesitan al menos una opción');
return;
}
setGuardando(true);
try {
const payload = {
name: name.trim(),
tipo,
descripcion,
duracion_estimada_min: duracion,
requiere_firma: requiereFirma,
requiere_foto: requiereFoto,
texto_legal: textoLegal,
campos: campos.map((c) => ({
clave: c.clave.trim(),
etiqueta: (c.etiqueta || c.clave).trim(),
tipo: c.tipo,
obligatorio: c.obligatorio,
ayuda: c.ayuda || '',
opciones: c.tipo === 'seleccion' || c.tipo === 'multi_seleccion' ? c.opciones : [],
...(c.tipo === 'foto' || c.tipo === 'archivo' ? { momento: c.momento || 'otro' } : {}),
})),
};
if (plantilla) await odooApi.updateFormularioPlantilla(plantilla.id, payload);
else await odooApi.createFormularioPlantilla(payload);
toast.success('Plantilla guardada');
onSaved();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo guardar la plantilla');
} finally {
setGuardando(false);
}
};
return (
<Modal
isOpen
onClose={onClose}
title={plantilla ? `Editar plantilla — ${plantilla.name}` : 'Nueva plantilla'}
maxWidth="full"
footer={
<>
<Button variant="outline" onClick={() => setPreview(true)}>
<Eye size={15} className="mr-1.5" /> Vista previa
</Button>
<Button variant="outline" onClick={onClose}>Cancelar</Button>
{canSave && (
<Button loading={guardando} onClick={guardar}>
<Save size={16} className="mr-2" /> Guardar
</Button>
)}
</>
}
>
<div className="max-h-[72vh] space-y-5 overflow-y-auto pr-1">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input label="Nombre" value={name} onChange={(e) => setName(e.target.value)} placeholder="Consentimiento de láser" />
<Select
label="Tipo"
value={tipo}
onChange={(e) => setTipo(e.target.value as typeof tipo)}
options={PLANTILLA_TIPOS}
/>
</div>
<TextArea label={`Descripción para el ${term('patient_singular').toLowerCase()} (opcional)`} value={descripcion} rows={2}
onChange={(e) => setDescripcion(e.target.value)} />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Input label="Duración estimada (min)" type="number" min={0} max={120}
value={String(duracion)} onChange={(e) => setDuracion(Number(e.target.value) || 0)} />
<Toggle label="Requiere firma" checked={requiereFirma} onChange={setRequiereFirma} />
<Toggle label="Requiere foto" checked={requiereFoto} onChange={setRequiereFoto} />
</div>
<TextArea
label="Texto legal / consentimiento"
value={textoLegal}
rows={5}
onChange={(e) => setTextoLegal(e.target.value)}
placeholder={`Se muestra al ${term('patient_singular').toLowerCase()} antes de firmar. Acepta saltos de línea.`}
/>
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-medium text-theme-heading">Campos del formulario</p>
<span className="text-xs text-theme-muted">{campos.length} campo{campos.length === 1 ? '' : 's'}</span>
</div>
<div className="space-y-2">
{campos.map((c, i) => (
<div key={i} className="rounded-xl border border-theme-border p-3">
<div className="flex items-center gap-2">
<GripVertical size={14} className="shrink-0 text-theme-muted" />
<div className="grid flex-1 grid-cols-1 gap-2 sm:grid-cols-2">
<Input
label="Etiqueta"
value={c.etiqueta}
placeholder="Nombre completo"
onChange={(e) => {
const valor = e.target.value;
setCampos((prev) => prev.map((x, j) => {
if (j !== i) return x;
const auto = !x.etiqueta || x.clave === slug(x.etiqueta);
return { ...x, etiqueta: valor, clave: auto ? (slug(valor) || x.clave) : x.clave };
}));
}}
/>
<Select
label="Tipo"
value={c.tipo}
onChange={(e) => actualizar(i, { tipo: e.target.value as FormularioCampoTipo })}
options={CAMPO_TIPOS.map((t) => ({ value: t.tipo, label: t.label }))}
/>
</div>
<div className="flex shrink-0 flex-col gap-1">
<button type="button" onClick={() => mover(i, -1)} className="rounded p-1 text-theme-muted hover:bg-theme-bg" title="Subir">
<ChevronUp size={14} />
</button>
<button type="button" onClick={() => mover(i, 1)} className="rounded p-1 text-theme-muted hover:bg-theme-bg" title="Bajar">
<ChevronDown size={14} />
</button>
<button type="button" onClick={() => quitar(i)} className="rounded p-1 text-rose-500 hover:bg-theme-bg" title="Quitar">
<Trash2 size={14} />
</button>
</div>
</div>
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
<Input label="Ayuda (opcional)" value={c.ayuda} placeholder="Texto de apoyo bajo el campo"
onChange={(e) => actualizar(i, { ayuda: e.target.value })} />
<Input label="Clave (identificador)" value={c.clave}
onChange={(e) => actualizar(i, { clave: slug(e.target.value) || e.target.value })} />
</div>
{(c.tipo === 'seleccion' || c.tipo === 'multi_seleccion') && (
<div className="mt-2">
<Input
label="Opciones (separadas por coma)"
value={c.opciones.join(', ')}
placeholder="Seca, Mixta, Grasa"
onChange={(e) => actualizar(i, {
opciones: e.target.value.split(',').map((s) => s.trim()).filter(Boolean),
})}
/>
</div>
)}
{(c.tipo === 'foto' || c.tipo === 'archivo') && (
<div className="mt-2 sm:max-w-xs">
<Select
label="Momento (galería del expediente)"
value={c.momento || 'otro'}
onChange={(e) => actualizar(i, { momento: e.target.value as FormularioCampo['momento'] })}
options={Object.entries(MOMENTO_LABEL).map(([value, label]) => ({ value, label }))}
/>
</div>
)}
<label className="mt-2 flex cursor-pointer items-center gap-2 text-sm text-theme-muted">
<input type="checkbox" checked={c.obligatorio}
onChange={(e) => actualizar(i, { obligatorio: e.target.checked })}
className="h-4 w-4 accent-theme-accent" />
Obligatorio
</label>
</div>
))}
{campos.length === 0 && (
<p className="rounded-xl border border-dashed border-theme-border-strong p-4 text-center text-sm text-theme-muted">
Sin campos todavía. Agrega el primero.
</p>
)}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{(['texto', 'texto_largo', 'numero', 'fecha', 'seleccion', 'multi_seleccion', 'checkbox', 'escala', 'firma', 'foto', 'archivo', 'consentimiento'] as FormularioCampoTipo[]).map((t) => (
<button
key={t}
type="button"
onClick={() => agregar(t)}
className="rounded-full bg-theme-accent-bg px-3 py-1.5 text-xs font-medium text-theme-heading hover:bg-theme-bg"
>
<Plus size={11} className="mr-1 inline" /> {TIPO_LABEL[t]}
</button>
))}
</div>
</div>
</div>
<Modal
isOpen={preview}
onClose={() => setPreview(false)}
title={`Vista previa (tal como la ve el ${term('patient_singular').toLowerCase()})`}
maxWidth="lg"
>
<div className="max-h-[70vh] overflow-y-auto">
<p className="mb-3 rounded-xl bg-theme-accent-bg p-3 text-sm font-medium text-theme-heading">{name || 'Formulario'}</p>
<FormularioCampos
campos={campos}
valores={previewValores}
onChange={() => {}}
firma={null}
onFirma={() => {}}
firmaNombre=""
onFirmaNombre={() => {}}
fotos={[]}
setFotos={() => {}}
textoLegal={textoLegal}
requiereFirma={requiereFirma}
requiereFoto={requiereFoto}
/>
</div>
</Modal>
</Modal>
);
};
const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void }> = ({ label, checked, onChange }) => (
<label className="flex cursor-pointer items-center gap-2 self-end rounded-xl border border-theme-border px-3 py-3 text-sm text-theme-heading">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} className="h-4 w-4 accent-theme-accent" />
{label}
</label>
);
export default FormulariosPanel;

View File

@@ -23,6 +23,7 @@ const TIPOS_PLANTILLA: { key: keyof NotificacionesSettings; label: string }[] =
{ key: 'plantilla_confirmacion', label: 'Confirmación' },
{ key: 'plantilla_cumpleanos', label: 'Cumpleaños' },
{ key: 'plantilla_post_visita', label: 'Post-visita' },
{ key: 'plantilla_formulario', label: 'Formulario digital' },
];
const NotificacionesPanel: FC<Props> = ({ settings, onSaved }) => {
@@ -51,6 +52,8 @@ const NotificacionesPanel: FC<Props> = ({ settings, onSaved }) => {
doctor: 'Dra. García',
sucursal: settings.nombre || 'Sucursal Centro',
telefono: settings.telefono || '+52 661 000 0000',
enlace: 'https://clinica.mx/formulario/abc123',
formulario: 'Consentimiento informado',
}), [term, settings.nombre, settings.telefono]);
const previsualizar = (plantilla: string) =>

View File

@@ -0,0 +1,354 @@
import type { FC } from 'react';
import { useRef } from 'react';
import { Camera, FileText, Paperclip, X } from 'lucide-react';
import type { FormularioCampo } from '../../services/odoo';
import FirmaCanvas from './FirmaCanvas';
// Renderiza un campo del formulario según su tipo. Pensado mobile-first: áreas
// táctiles grandes, radios en vez de selects nativos y la cámara del celular
// para los campos de foto.
export interface FotoLocal {
data_url: string;
name: string;
}
interface Props {
campo: FormularioCampo;
valor: unknown;
onChange: (v: unknown) => void;
error?: string;
disabled?: boolean;
firma?: string | null;
onFirma?: (v: string | null) => void;
fotos?: FotoLocal[];
onFotos?: (items: FotoLocal[]) => void;
onQuitarFoto?: (index: number) => void;
textoLegal?: string;
}
const MAX_MB = 5;
const MAX_IMG_MB = 25; // original máximo aceptado antes de reducir
const MAX_LADO = 1600; // lado mayor de la foto ya reducida
const CALIDAD_JPG = 0.82;
/** Reduce una imagen en el navegador con canvas (sin librerías) para que la
* subida sea ligera desde el celular. Devuelve un data URL JPEG. */
const comprimirImagen = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
try {
const escala = Math.min(1, MAX_LADO / Math.max(img.width, img.height));
const w = Math.max(1, Math.round(img.width * escala));
const h = Math.max(1, Math.round(img.height * escala));
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Sin canvas');
ctx.drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', CALIDAD_JPG));
} catch (e) {
reject(e);
} finally {
URL.revokeObjectURL(url);
}
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Imagen no válida')); };
img.src = url;
});
const leerUno = (file: File, esImagen: boolean): Promise<FotoLocal | null> => {
if (esImagen) {
if (file.size > MAX_IMG_MB * 1024 * 1024) return Promise.resolve(null);
return comprimirImagen(file)
.then((data_url) => ({ data_url, name: file.name }))
.catch(() => (file.size <= MAX_MB * 1024 * 1024
? new Promise<FotoLocal>((res) => {
const r = new FileReader();
r.onload = () => res({ data_url: String(r.result || ''), name: file.name });
r.onerror = () => res(null as unknown as FotoLocal);
r.readAsDataURL(file);
})
: Promise.resolve(null)));
}
if (file.size > MAX_MB * 1024 * 1024) return Promise.resolve(null);
return new Promise((res) => {
const reader = new FileReader();
reader.onload = () => res({ data_url: String(reader.result || ''), name: file.name });
reader.onerror = () => res(null);
reader.readAsDataURL(file);
});
};
const leerArchivos = (files: File[], esImagen: boolean, cb: (items: FotoLocal[]) => void) => {
if (!files.length) return;
Promise.all(files.map((f) => leerUno(f, esImagen)))
.then((items) => cb(items.filter((i): i is FotoLocal => !!i && !!i.data_url)));
};
const CampoFormulario: FC<Props> = ({
campo, valor, onChange, error, disabled = false,
firma, onFirma, fotos = [], onFotos, onQuitarFoto, textoLegal,
}) => {
const fileRef = useRef<HTMLInputElement>(null);
const esFoto = campo.tipo === 'foto';
const acepta = esFoto ? 'image/*' : undefined;
const etiqueta = (
<span className="flex items-center gap-1 text-sm font-medium text-theme-heading">
{campo.etiqueta}
{campo.obligatorio && <span className="text-rose-500">*</span>}
</span>
);
const ayuda = campo.ayuda ? <span className="mt-0.5 block text-xs text-theme-muted">{campo.ayuda}</span> : null;
const errorEl = error ? <span className="mt-1 block text-xs font-medium text-rose-600">{error}</span> : null;
const claseInput =
'w-full rounded-xl border border-theme-border bg-theme-surface px-3 py-3 text-base text-theme-heading ' +
'focus:border-theme-border-strong focus:outline-none disabled:opacity-60';
const cuerpo = () => {
switch (campo.tipo) {
case 'texto_largo':
return (
<textarea
id={campo.clave}
name={campo.clave}
className={claseInput}
rows={4}
value={String(valor ?? '')}
disabled={disabled}
placeholder={campo.ayuda || ''}
onChange={(e) => onChange(e.target.value)}
/>
);
case 'numero':
return (
<input
id={campo.clave}
name={campo.clave}
type="number"
inputMode="decimal"
className={claseInput}
value={valor === undefined || valor === null ? '' : String(valor)}
disabled={disabled}
onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
/>
);
case 'fecha':
return (
<input
id={campo.clave}
name={campo.clave}
type="date"
className={claseInput}
value={String(valor ?? '')}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
/>
);
case 'seleccion':
return (
<div className="space-y-2">
{(campo.opciones || []).map((op, idx) => (
<label
key={op}
className={`flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-3 text-sm ${
valor === op ? 'border-theme-border-strong bg-theme-accent-bg' : 'border-theme-border bg-theme-surface'
}`}
>
<input
id={`${campo.clave}-${idx}`}
type="radio"
name={campo.clave}
value={op}
checked={valor === op}
disabled={disabled}
onChange={() => onChange(op)}
className="h-4 w-4 accent-theme-accent"
/>
<span className="text-theme-heading">{op}</span>
</label>
))}
</div>
);
case 'multi_seleccion': {
const seleccion = Array.isArray(valor) ? (valor as string[]) : [];
return (
<div className="space-y-2">
{(campo.opciones || []).map((op) => {
const activo = seleccion.includes(op);
return (
<label
key={op}
className={`flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-3 text-sm ${
activo ? 'border-theme-border-strong bg-theme-accent-bg' : 'border-theme-border bg-theme-surface'
}`}
>
<input
id={`${campo.clave}-${op}`}
name={campo.clave}
type="checkbox"
checked={activo}
disabled={disabled}
onChange={() => onChange(activo ? seleccion.filter((s) => s !== op) : [...seleccion, op])}
className="h-4 w-4 accent-theme-accent"
/>
<span className="text-theme-heading">{op}</span>
</label>
);
})}
</div>
);
}
case 'checkbox':
return (
<label className="flex cursor-pointer items-center gap-3 rounded-xl border border-theme-border bg-theme-surface px-3 py-3 text-sm">
<input
id={campo.clave}
name={campo.clave}
type="checkbox"
checked={Boolean(valor)}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="h-5 w-5 accent-theme-accent"
/>
<span className="text-theme-heading">{campo.etiqueta}</span>
</label>
);
case 'escala': {
const n = Number(valor);
return (
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: 10 }, (_, i) => i + 1).map((v) => (
<button
key={v}
type="button"
data-value={v}
disabled={disabled}
onClick={() => onChange(v)}
className={`rounded-xl border py-3 text-base font-medium ${
n === v ? 'border-theme-border-strong bg-theme-accent text-theme-inverse' : 'border-theme-border bg-theme-surface text-theme-heading'
}`}
>
{v}
</button>
))}
</div>
);
}
case 'firma':
return (
<FirmaCanvas value={firma ?? null} onChange={(v) => onFirma?.(v)} disabled={disabled} />
);
case 'foto':
case 'archivo':
return (
<div>
<input
ref={fileRef}
type="file"
accept={acepta}
multiple={esFoto}
// En celular abre la cámara trasera para las fotos.
{...(esFoto ? { capture: 'environment' as const } : {})}
className="hidden"
disabled={disabled}
onChange={(e) => {
const files = Array.from(e.target.files || []);
e.target.value = '';
if (files.length) leerArchivos(files, esFoto, (items) => onFotos?.(items));
}}
/>
<button
type="button"
disabled={disabled}
onClick={() => fileRef.current?.click()}
className="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-theme-border-strong bg-theme-surface px-3 py-3 text-sm font-medium text-theme-heading"
>
{esFoto ? <Camera size={16} /> : <Paperclip size={16} />}
{esFoto ? 'Tomar foto o elegir de la galería' : 'Elegir archivo'}
</button>
{fotos.length > 0 && (
<div className="mt-2 grid grid-cols-3 gap-2">
{fotos.map((f, i) => (
<div key={`${f.name}-${i}`} className="relative">
{esFoto && f.data_url ? (
<img src={f.data_url} alt={f.name} className="h-20 w-full rounded-xl border border-theme-border object-cover" />
) : (
<div className="flex h-20 items-center justify-center rounded-xl border border-theme-border bg-theme-bg px-2 text-center text-[11px] text-theme-muted">
<FileText size={14} className="mr-1 shrink-0" /> <span className="truncate">{f.name}</span>
</div>
)}
{!disabled && (
<button
type="button"
onClick={() => onQuitarFoto?.(i)}
className="absolute right-1 top-1 rounded-full bg-theme-surface/90 p-1 text-theme-muted hover:text-rose-600"
title="Quitar"
>
<X size={12} />
</button>
)}
</div>
))}
</div>
)}
</div>
);
case 'consentimiento':
return (
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-theme-border bg-theme-surface px-3 py-3 text-sm">
<input
id={campo.clave}
name={campo.clave}
type="checkbox"
checked={Boolean(valor)}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="mt-0.5 h-5 w-5 shrink-0 accent-theme-accent"
/>
<span className="text-theme-heading">{campo.etiqueta}</span>
</label>
);
default:
return (
<input
id={campo.clave}
name={campo.clave}
type="text"
className={claseInput}
value={String(valor ?? '')}
disabled={disabled}
placeholder={campo.ayuda || ''}
onChange={(e) => onChange(e.target.value)}
/>
);
}
};
return (
<div className="py-3" {...(error ? { 'data-error': '1' } : {})}>
{campo.tipo !== 'checkbox' && campo.tipo !== 'consentimiento' && (
<div className="mb-2">
{etiqueta}
{ayuda}
</div>
)}
{campo.tipo === 'consentimiento' && textoLegal && (
<div className="mb-2 max-h-56 overflow-y-auto rounded-xl bg-theme-bg p-3 text-xs leading-relaxed text-theme-muted whitespace-pre-wrap">
{textoLegal}
</div>
)}
{cuerpo()}
{campo.tipo === 'consentimiento' && ayuda}
{errorEl}
</div>
);
};
export default CampoFormulario;

View File

@@ -0,0 +1,170 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Copy, ExternalLink, Send } from 'lucide-react';
import { Button, Modal, Select, toast } from '../ui';
import { PatientCombobox } from '../PatientCombobox';
import { odooApi, type FormularioCanal, type FormularioPlantilla } from '../../services/odoo';
import { urlBasePublica } from '../../lib/publicBooking';
import { useClinicSettings } from '../../lib/clinic-settings';
import { useGlosario } from '../../lib/glosario';
// Modal reutilizable para enviar un formulario a un paciente. Se abre desde el
// expediente del paciente y desde el detalle de la cita. Canal 'enlace'
// devuelve la liga para compartir; whatsapp/email la encolan por el motor de
// notificaciones (si está apagado, la liga se devuelve igual).
interface Props {
isOpen: boolean;
onClose: () => void;
patientId?: number | null;
patientName?: string;
citaId?: number | null;
onSent?: () => void;
}
const EnviarFormularioModal: FC<Props> = ({ isOpen, onClose, patientId = null, patientName = '', citaId = null, onSent }) => {
const settings = useClinicSettings();
const { term } = useGlosario();
const [plantillas, setPlantillas] = useState<FormularioPlantilla[]>([]);
const [plantillaId, setPlantillaId] = useState('');
const [paciente, setPaciente] = useState<string>(patientId ? String(patientId) : '');
const [canal, setCanal] = useState<FormularioCanal>('enlace');
const [enviando, setEnviando] = useState(false);
const [enlace, setEnlace] = useState<string | null>(null);
const [notificado, setNotificado] = useState(false);
const [copiado, setCopiado] = useState(false);
useEffect(() => {
if (!isOpen) return;
setEnlace(null);
setCopiado(false);
if (patientId) setPaciente(String(patientId));
odooApi.getFormularioPlantillas()
.then((res) => {
if (res.status === 'success') {
setPlantillas(res.plantillas);
setPlantillaId((prev) => prev || (res.plantillas[0] ? String(res.plantillas[0].id) : ''));
}
})
.catch(() => toast.error('No se pudieron cargar las plantillas'));
}, [isOpen, patientId]);
const enlaceCompleto = enlace ? (enlace.startsWith('http') ? enlace : `${urlBasePublica(settings.url_publica)}${enlace}`) : '';
const enviar = async () => {
if (!plantillaId) { toast.error('Elige una plantilla'); return; }
if (!paciente) { toast.error(`Elige un ${term('patient_singular').toLowerCase()}`); return; }
setEnviando(true);
try {
const res = await odooApi.enviarFormulario({
plantilla_id: Number(plantillaId),
partner_id: Number(paciente),
cita_id: citaId || null,
canal,
});
setEnlace(res.enlace);
setNotificado(!!res.notificado);
onSent?.();
toast.success('Formulario enviado');
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo enviar el formulario');
} finally {
setEnviando(false);
}
};
const copiar = async () => {
try {
await navigator.clipboard.writeText(enlaceCompleto);
setCopiado(true);
setTimeout(() => setCopiado(false), 2000);
} catch {
toast.error('No se pudo copiar el enlace');
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Enviar formulario"
maxWidth="lg"
footer={
enlace ? (
<Button variant="outline" onClick={onClose}>Cerrar</Button>
) : (
<>
<Button variant="outline" onClick={onClose}>Cancelar</Button>
<Button loading={enviando} onClick={enviar}>
<Send size={16} className="mr-2" /> Enviar
</Button>
</>
)
}
>
{plantillas.length === 0 ? (
<p className="text-sm text-theme-muted">
Aún no hay plantillas de formulario. Crea una en Configuración › Formularios.
</p>
) : enlace ? (
<div className="space-y-4">
<p className="text-sm text-theme-heading">
El formulario quedó pendiente. Comparte esta liga con {patientName || `el ${term('patient_singular').toLowerCase()}`}:
</p>
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-theme-border bg-theme-bg p-3">
<input
readOnly
value={enlaceCompleto}
className="min-w-0 flex-1 rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-xs text-theme-heading"
/>
<Button variant="ghost" onClick={copiar}>
<Copy size={14} className="mr-1" /> {copiado ? 'Copiado' : 'Copiar'}
</Button>
<Button variant="ghost" onClick={() => window.open(enlaceCompleto, '_blank', 'noopener')}>
<ExternalLink size={14} className="mr-1" /> Abrir
</Button>
</div>
{canal !== 'enlace' && (
<p className="text-xs text-theme-muted">
{notificado
? `Se encoló el aviso por ${canal === 'whatsapp' ? 'WhatsApp' : 'correo'}.`
: 'No se envió el aviso automático (notificaciones apagadas, modo prueba o sin dato de contacto). Comparte la liga manualmente.'}
</p>
)}
</div>
) : (
<div className="space-y-4">
<Select
label="Plantilla"
value={plantillaId}
onChange={(e) => setPlantillaId(e.target.value)}
options={plantillas.map((p) => ({ value: String(p.id), label: p.name }))}
/>
{patientId ? (
<div>
<p className="mb-1 text-sm font-medium text-theme-heading">{term('patient_singular')}</p>
<p className="rounded-xl border border-theme-border bg-theme-bg px-3 py-2.5 text-sm text-theme-heading">
{patientName || `#${patientId}`}
</p>
</div>
) : (
<PatientCombobox value={paciente} onChange={(id) => setPaciente(id)} />
)}
<Select
label="Canal"
value={canal}
onChange={(e) => setCanal(e.target.value as FormularioCanal)}
options={[
{ value: 'enlace', label: 'Enlace (compartir manualmente)' },
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'email', label: 'Correo' },
]}
/>
</div>
)}
</Modal>
);
};
export default EnviarFormularioModal;

View File

@@ -0,0 +1,124 @@
import type { FC } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Eraser, PenLine } from 'lucide-react';
// Firma en canvas con el dedo (o el mouse). Sin librerías: pointer events y
// un trazo de grosor generoso, pensado para una mano y pantalla de celular.
// El valor que sube al padre es un data URL PNG (o null si se limpia).
interface Props {
value: string | null;
onChange: (dataUrl: string | null) => void;
disabled?: boolean;
height?: number;
}
const FirmaCanvas: FC<Props> = ({ value, onChange, disabled = false, height = 200 }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const dibujando = useRef(false);
const hayTrazo = useRef(false);
const [vacio, setVacio] = useState(!value);
const preparar = () => {
const canvas = canvasRef.current;
if (!canvas) return null;
const ratio = window.devicePixelRatio || 1;
const ancho = canvas.clientWidth || 320;
canvas.width = Math.round(ancho * ratio);
canvas.height = Math.round(height * ratio);
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, ancho, height);
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#111111';
return ctx;
};
useEffect(() => {
preparar();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [height]);
const posicion = (e: React.PointerEvent<HTMLCanvasElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
const empezar = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (disabled) return;
const ctx = canvasRef.current?.getContext('2d');
if (!ctx) return;
e.currentTarget.setPointerCapture(e.pointerId);
dibujando.current = true;
const { x, y } = posicion(e);
ctx.beginPath();
ctx.moveTo(x, y);
};
const mover = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!dibujando.current || disabled) return;
const ctx = canvasRef.current?.getContext('2d');
if (!ctx) return;
const { x, y } = posicion(e);
ctx.lineTo(x, y);
ctx.stroke();
hayTrazo.current = true;
if (vacio) setVacio(false);
};
const terminar = () => {
if (!dibujando.current) return;
dibujando.current = false;
if (hayTrazo.current && canvasRef.current) {
onChange(canvasRef.current.toDataURL('image/png'));
setVacio(false);
}
};
const limpiar = () => {
preparar();
hayTrazo.current = false;
setVacio(true);
onChange(null);
};
return (
<div>
<div className="relative rounded-2xl border-2 border-dashed border-theme-border-strong bg-white overflow-hidden">
<canvas
ref={canvasRef}
onPointerDown={empezar}
onPointerMove={mover}
onPointerUp={terminar}
onPointerLeave={terminar}
onPointerCancel={terminar}
style={{ touchAction: 'none', height, width: '100%', display: 'block' }}
/>
{vacio && !disabled && (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-theme-muted">
<PenLine size={22} />
<span className="mt-1 text-xs">Firma aquí con el dedo</span>
</div>
)}
</div>
{!disabled && (
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-theme-muted">Toca y arrastra para firmar</span>
<button
type="button"
onClick={limpiar}
className="inline-flex items-center gap-1 rounded-full bg-theme-accent-bg px-3 py-1 text-xs font-medium text-theme-heading hover:bg-theme-bg"
>
<Eraser size={13} /> Limpiar
</button>
</div>
)}
</div>
);
};
export default FirmaCanvas;

View File

@@ -0,0 +1,140 @@
import type { FC } from 'react';
import { Camera, ShieldCheck } from 'lucide-react';
import type { FormularioCampo, FormularioMomento } from '../../services/odoo';
import CampoFormulario, { type FotoLocal } from './CampoFormulario';
// Contenedor compartido por el portal público y la vista previa del constructor:
// pinta los campos en orden, el texto legal, la firma y las fotos. El estado
// (valores, firma, fotos) lo guarda quien lo usa.
export interface FotoConCampo extends FotoLocal {
campo_clave: string;
kind?: FormularioMomento;
}
interface Props {
campos: FormularioCampo[];
valores: Record<string, unknown>;
onChange: (clave: string, v: unknown) => void;
firma?: string | null;
onFirma?: (v: string | null) => void;
firmaNombre?: string;
onFirmaNombre?: (v: string) => void;
fotos?: FotoConCampo[];
setFotos?: (f: FotoConCampo[]) => void;
errores?: Record<string, string>;
disabled?: boolean;
textoLegal?: string;
requiereFirma?: boolean;
requiereFoto?: boolean;
}
const FormularioCampos: FC<Props> = ({
campos, valores, onChange, firma = null, onFirma, firmaNombre = '', onFirmaNombre,
fotos = [], setFotos, errores = {}, disabled = false, textoLegal = '',
requiereFirma = false, requiereFoto = false,
}) => {
const tieneConsentimiento = campos.some((c) => c.tipo === 'consentimiento');
const tieneFirmaCampo = campos.some((c) => c.tipo === 'firma');
const tieneFotoCampo = campos.some((c) => c.tipo === 'foto');
const pideFirma = requiereFirma || tieneFirmaCampo;
const fotosGenerales = fotos.filter((f) => f.campo_clave === '__general');
const agregar = (clave: string, items: FotoLocal[]) => {
if (!setFotos) return;
const validos = items.filter((i) => i.data_url);
setFotos([...fotos, ...validos.map((i) => ({ campo_clave: clave, ...i }))]);
};
const quitar = (clave: string, idx: number) => {
if (!setFotos) return;
const propias = fotos.filter((f) => f.campo_clave === clave);
const objetivo = propias[idx];
setFotos(fotos.filter((f) => f !== objetivo));
};
return (
<div className="divide-y divide-theme-border">
{textoLegal && !tieneConsentimiento && (
<div className="py-3">
<div className="max-h-72 overflow-y-auto rounded-2xl bg-theme-bg p-4 text-sm leading-relaxed text-theme-muted whitespace-pre-wrap">
{textoLegal}
</div>
</div>
)}
{campos.map((campo) => (
<CampoFormulario
key={campo.clave}
campo={campo}
valor={valores[campo.clave] ?? ''}
onChange={(v) => onChange(campo.clave, v)}
error={errores[campo.clave]}
disabled={disabled}
firma={firma}
onFirma={onFirma}
fotos={fotos.filter((f) => f.campo_clave === campo.clave)}
onFotos={(items) => agregar(campo.clave, items)}
onQuitarFoto={(i) => quitar(campo.clave, i)}
textoLegal={textoLegal}
/>
))}
{requiereFoto && !tieneFotoCampo && (
<div className="py-3">
<CampoFormulario
campo={{ clave: '__general', etiqueta: 'Fotos', tipo: 'foto', obligatorio: true, ayuda: '', opciones: [], momento: 'otro' }}
valor={fotosGenerales.length > 0}
onChange={() => {}}
error={errores.__fotos}
disabled={disabled}
fotos={fotosGenerales}
onFotos={(items) => agregar('__general', items)}
onQuitarFoto={(i) => quitar('__general', i)}
/>
</div>
)}
{pideFirma && !tieneFirmaCampo && (
<div className="py-3">
<span className="mb-2 flex items-center gap-1 text-sm font-medium text-theme-heading">
<ShieldCheck size={15} /> Firma <span className="text-rose-500">*</span>
</span>
<CampoFormulario
campo={{ clave: 'firma', etiqueta: 'Firma', tipo: 'firma', obligatorio: true, ayuda: '', opciones: [] }}
valor={Boolean(firma)}
onChange={() => {}}
error={errores.firma || errores.__firma}
disabled={disabled}
firma={firma}
onFirma={onFirma}
/>
</div>
)}
{pideFirma && (
<div className="py-3">
<label className="mb-2 block text-sm font-medium text-theme-heading">
Firmado por <span className="text-rose-500">*</span>
</label>
<input
id="firma_nombre"
type="text"
value={firmaNombre}
disabled={disabled}
onChange={(e) => onFirmaNombre?.(e.target.value)}
placeholder="Nombre completo de quien firma"
className="w-full rounded-xl border border-theme-border bg-theme-surface px-3 py-3 text-base text-theme-heading focus:border-theme-border-strong focus:outline-none disabled:opacity-60"
/>
{!tieneFotoCampo && (
<p className="mt-2 flex items-center gap-1 text-xs text-theme-muted">
<Camera size={12} /> Puedes adjuntar fotos en los campos de arriba.
</p>
)}
</div>
)}
</div>
);
};
export default FormularioCampos;

View File

@@ -0,0 +1,122 @@
import type { FormularioCampo, FormularioCampoTipo, FormularioMomento } from '../../services/odoo';
// Catálogo y utilidades compartidas por el constructor, la vista previa y el
// portal público de formularios. Sin dependencias: un espejo del backend.
export const CAMPO_TIPOS: { tipo: FormularioCampoTipo; label: string; descripcion: string }[] = [
{ tipo: 'texto', label: 'Texto corto', descripcion: 'Una línea (nombre, teléfono…)' },
{ tipo: 'texto_largo', label: 'Texto largo', descripcion: 'Varias líneas' },
{ tipo: 'numero', label: 'Número', descripcion: 'Cantidad o peso' },
{ tipo: 'fecha', label: 'Fecha', descripcion: 'Selector de fecha' },
{ tipo: 'seleccion', label: 'Una opción', descripcion: 'Elegir una de varias' },
{ tipo: 'multi_seleccion', label: 'Varias opciones', descripcion: 'Elegir varias' },
{ tipo: 'checkbox', label: 'Casilla', descripcion: 'Sí / no' },
{ tipo: 'escala', label: 'Escala 1-10', descripcion: 'Nivel o intensidad' },
{ tipo: 'firma', label: 'Firma', descripcion: 'Firma con el dedo' },
{ tipo: 'foto', label: 'Foto', descripcion: 'Cámara o galería' },
{ tipo: 'archivo', label: 'Archivo', descripcion: 'Documento o estudio' },
{ tipo: 'consentimiento', label: 'Consentimiento', descripcion: 'Aceptar el texto legal' },
];
export const TIPO_LABEL: Record<FormularioCampoTipo, string> =
Object.fromEntries(CAMPO_TIPOS.map((t) => [t.tipo, t.label])) as Record<FormularioCampoTipo, string>;
export const PLANTILLA_TIPOS: { value: string; label: string }[] = [
{ value: 'consentimiento', label: 'Consentimiento informado' },
{ value: 'expediente', label: 'Nota de expediente' },
{ value: 'encuesta', label: 'Encuesta' },
{ value: 'autorizacion', label: 'Autorización' },
];
export const MOMENTO_LABEL: Record<FormularioMomento, string> = {
antes: 'Antes',
despues: 'Después',
otro: 'Otro',
};
export const ESTADO_LABEL: Record<string, string> = {
borrador: 'Borrador',
enviado: 'Pendiente',
llenado: 'Lleno',
cancelado: 'Cancelado',
};
/** True si el formulario necesita firma (bandera de la plantilla o campo firma). */
export const necesitaFirma = (campos: FormularioCampo[], requiereFirma?: boolean): boolean =>
Boolean(requiereFirma) || campos.some((c) => c.tipo === 'firma');
/** True si necesita al menos una foto (bandera de la plantilla). */
export const necesitaFoto = (campos: FormularioCampo[], requiereFoto?: boolean): boolean =>
Boolean(requiereFoto) || campos.some((c) => c.tipo === 'foto' && c.obligatorio);
export const valorInicial = (campo: FormularioCampo): unknown => {
switch (campo.tipo) {
case 'multi_seleccion': return [];
case 'checkbox':
case 'consentimiento':
case 'firma':
case 'foto':
case 'archivo': return false;
default: return '';
}
};
/** Interruptores que la plantilla exige sí o sí (se muestran sin poder apagarse). */
export const firmaObligatoria = (campos: FormularioCampo[]): boolean =>
campos.some((c) => c.tipo === 'firma' && c.obligatorio);
/** Validación en el cliente (el backend vuelve a validar). Devuelve errores por clave. */
export function validarLocal(
campos: FormularioCampo[],
valores: Record<string, unknown>,
firma: string | null,
fotos: { campo_clave: string }[],
requiereFirma: boolean,
requiereFoto: boolean,
): Record<string, string> {
const errores: Record<string, string> = {};
for (const campo of campos) {
const v = valores[campo.clave];
const vacio = v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
switch (campo.tipo) {
case 'consentimiento':
if (campo.obligatorio && !v) errores[campo.clave] = 'Debes aceptar para continuar';
break;
case 'firma':
if (campo.obligatorio && !firma) errores[campo.clave] = 'Falta tu firma';
break;
case 'foto':
case 'archivo':
if (campo.obligatorio && !fotos.some((f) => f.campo_clave === campo.clave)) {
errores[campo.clave] = campo.tipo === 'foto' ? 'Falta una foto' : 'Falta adjuntar el archivo';
}
break;
case 'escala': {
if (vacio) { if (campo.obligatorio) errores[campo.clave] = 'Elige un valor'; break; }
const n = Number(v);
if (!(n >= 1 && n <= 10)) errores[campo.clave] = 'Debe ser del 1 al 10';
break;
}
case 'numero':
if (vacio) { if (campo.obligatorio) errores[campo.clave] = 'Falta este dato'; break; }
if (Number.isNaN(Number(v))) errores[campo.clave] = 'Escribe un número';
break;
case 'checkbox':
break;
default:
if (campo.obligatorio && vacio) errores[campo.clave] = 'Falta responder';
}
}
if (requiereFirma && !firma) errores.__firma = 'Este formulario requiere firma';
if (requiereFoto && fotos.length === 0) errores.__fotos = 'Este formulario requiere al menos una foto';
return errores;
}
/** Texto legible del valor de un campo, para el detalle de la respuesta. */
export function valorLegible(campo: FormularioCampo, valor: unknown): string {
if (valor === undefined || valor === null || valor === '') return '—';
if (Array.isArray(valor)) return valor.length ? valor.join(', ') : '—';
if (typeof valor === 'boolean') return valor ? 'Sí' : 'No';
if (campo.tipo === 'escala') return `${valor} / 10`;
return String(valor);
}

View File

@@ -16,6 +16,7 @@ export const PERM_KEYS: string[] = [
'reportes.ver', 'reportes.exportar', 'caja.ver', 'caja.cerrar',
'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar',
'notificaciones.ver', 'notificaciones.enviar', 'notificaciones.editar',
'formularios.ver', 'formularios.crear', 'formularios.editar', 'formularios.enviar',
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
];
@@ -26,6 +27,8 @@ const MONEY = W.filter((r) => r !== 'medico' && r !== 'marketing');
const AG = ['admin', 'gerencia'];
const AD = ['admin'];
const ALL = [...W, 'lectura'];
// Llenar/enviar formularios: quien atiende al paciente.
const FORM_WRITE = ['admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'];
const READ_PERM_KEYS = new Set([
'agenda.ver', 'agenda.todas',
@@ -37,6 +40,7 @@ const READ_PERM_KEYS = new Set([
'ventas.reimprimir',
'whatsapp.ver',
'notificaciones.ver',
'formularios.ver',
]);
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
@@ -56,6 +60,8 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'reportes.ver': ALL, 'reportes.exportar': ALL, 'caja.ver': ALL, 'caja.cerrar': MONEY,
'whatsapp.ver': W, 'whatsapp.responder': W, 'whatsapp.asignar': W,
'notificaciones.ver': ALL, 'notificaciones.enviar': W, 'notificaciones.editar': AG,
'formularios.ver': ALL, 'formularios.crear': FORM_WRITE, 'formularios.editar': AG,
'formularios.enviar': FORM_WRITE,
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
};

View File

@@ -1,12 +1,13 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall, Wallet, ChevronDown, CalendarOff, Users, Play, Check, Globe, Bell } from 'lucide-react';
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall, Wallet, ChevronDown, CalendarOff, Users, Play, Check, Globe, Bell, FileSignature } from 'lucide-react';
import Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer';
import AgendaPrint from '../components/AgendaPrint';
import PatientCombobox from '../components/PatientCombobox';
import RecargarSaldoModal from '../components/RecargarSaldoModal';
import ReciboPrint from '../components/ReciboPrint';
import EnviarFormularioModal from '../components/formulario/EnviarFormularioModal';
import BloqueosModal, { quickCloseOptions, type BloqueoQuickClose } from '../components/agenda/BloqueosModal';
import { ListaEsperaPanel, ListaEsperaModal, whatsappLink } from '../components/agenda/ListaEspera';
import { EstadoChecklist, CHECKLIST_STEPS, stepIndex } from '../components/agenda/EstadoChecklist';
@@ -234,6 +235,7 @@ const Agenda: FC = () => {
// sin depender de la búsqueda ni de la lista de citas cargadas).
const [pickedPatient, setPickedPatient] = useState<{ id: string; name: string } | null>(null);
const [detailApt, setDetailApt] = useState<Appointment | null>(null);
const [enviarFormApt, setEnviarFormApt] = useState<Appointment | null>(null);
const [editMode, setEditMode] = useState(false);
const [editForm, setEditForm] = useState({ date: '', time: '', doctor_id: '', notes: '' });
const [savingEdit, setSavingEdit] = useState(false);
@@ -3051,6 +3053,10 @@ const Agenda: FC = () => {
<Button variant="outline" onClick={() => setEditMode(false)}>Volver</Button>
<Button onClick={saveAptEdit} loading={savingEdit}>Guardar cambios</Button>
</>
) : detailApt && can('formularios.enviar') ? (
<Button variant="outline" onClick={() => setEnviarFormApt(detailApt)}>
<FileSignature size={16} className="mr-2" /> Enviar formulario
</Button>
) : undefined
}
>
@@ -3277,6 +3283,15 @@ const Agenda: FC = () => {
/>
<ReciboPrint receipt={reciboApt} onClose={() => setReciboApt(null)} />
{/* Envío de formulario (consentimiento/encuesta) al paciente de la cita. */}
<EnviarFormularioModal
isOpen={!!enviarFormApt}
onClose={() => setEnviarFormApt(null)}
patientId={enviarFormApt?.patient_id ?? null}
patientName={enviarFormApt?.patient}
citaId={enviarFormApt?.id ?? null}
/>
{/* Alta de paciente en la lista de espera */}
<ListaEsperaModal
isOpen={listaModalOpen}

View File

@@ -16,6 +16,7 @@ import TerminologiaPanel from '../components/TerminologiaPanel';
import ReservasOnlinePanel from '../components/ReservasOnlinePanel';
import NotificacionesPanel from '../components/NotificacionesPanel';
import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPanel';
import FormulariosPanel from '../components/FormulariosPanel';
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
import { useAuth } from '../lib/auth';
@@ -26,6 +27,7 @@ const TABS = [
{ key: 'reservas', label: 'Reservas en línea' },
{ key: 'notificaciones', label: 'Notificaciones' },
{ key: 'notif_enviadas', label: 'Notificaciones enviadas' },
{ key: 'formularios', label: 'Formularios' },
{ key: 'sucursales', label: 'Sucursales' },
{ key: 'categorias', label: 'Categorías' },
{ key: 'terminologia', label: 'Terminología' },
@@ -38,7 +40,7 @@ const TABS = [
] as const;
// Secciones que ve gerencia (admin del sistema ve todas).
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'sucursales', 'categorias', 'terminologia', 'usuarios', 'roles', 'tickets'];
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'terminologia', 'usuarios', 'roles', 'tickets'];
const SUBCATALOGOS = [
{ key: 'diagnosticos', label: 'Diagnósticos' },
@@ -61,6 +63,7 @@ const Configuracion: FC = () => {
auditoria: 'config.auditoria',
notificaciones: 'notificaciones.editar',
notif_enviadas: 'notificaciones.ver',
formularios: 'formularios.ver',
};
const tabsVisibles = useMemo(
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key)))
@@ -400,6 +403,12 @@ const Configuracion: FC = () => {
<NotificacionesEnviadasPanel />
</Card.Body>
</Card>
) : tab === 'formularios' ? (
<Card>
<Card.Body>
<FormulariosPanel />
</Card.Body>
</Card>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
<Card>

View File

@@ -0,0 +1,241 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { AlertTriangle, CheckCircle2, Loader2, Send } from 'lucide-react';
import { odooApi, type FormularioPublicoConfig } from '../services/odoo';
import FormularioCampos, { type FotoConCampo } from '../components/formulario/FormularioCampos';
import { necesitaFirma, necesitaFoto, validarLocal } from '../components/formulario/tipos';
// Portal público para que el paciente llene un formulario desde su celular,
// con firma y fotos. Fuera del gate de autenticación: el token HMAC del enlace
// es la única credencial. Respeta el brand y el glosario que manda el backend.
const FormularioPublico: FC = () => {
const { token = '' } = useParams();
const [config, setConfig] = useState<FormularioPublicoConfig | null>(null);
const [loading, setLoading] = useState(true);
const [noDisponible, setNoDisponible] = useState<string | null>(null);
const [valores, setValores] = useState<Record<string, unknown>>({});
const [firma, setFirma] = useState<string | null>(null);
const [firmaNombre, setFirmaNombre] = useState('');
const [fotos, setFotos] = useState<FotoConCampo[]>([]);
const [errores, setErrores] = useState<Record<string, string>>({});
const [enviando, setEnviando] = useState(false);
const [enviado, setEnviado] = useState(false);
const [errorEnvio, setErrorEnvio] = useState<string | null>(null);
const [yaLlenado, setYaLlenado] = useState(false);
const [hp, setHp] = useState('');
useEffect(() => {
let alive = true;
if (!token) { setLoading(false); setNoDisponible('Enlace no válido'); return; }
odooApi.getFormularioPublicoConfig(token)
.then((res) => {
if (!alive) return;
if (res.status !== 'success') { setNoDisponible('Enlace no válido o expirado'); return; }
setConfig(res);
setYaLlenado(res.ya_llenado);
setFirmaNombre((prev) => prev || res.paciente?.nombre || '');
const inicial: Record<string, unknown> = {};
for (const c of res.plantilla.campos) {
if (c.tipo === 'multi_seleccion') inicial[c.clave] = [];
}
setValores(inicial);
})
.catch((err) => {
const r = err as { response?: { status?: number } };
if (alive) setNoDisponible(r?.response?.status === 409 ? 'Ya no disponible' : 'Enlace no válido o expirado');
})
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [token]);
const campos = config?.plantilla.campos || [];
const pideFirma = necesitaFirma(campos, config?.plantilla.requiere_firma);
const pideFoto = necesitaFoto(campos, config?.plantilla.requiere_foto);
const term = (k: string, fallback: string) =>
(config?.terms as unknown as Record<string, string>)?.[k] || fallback;
const enviarLlenado = async () => {
if (!config) return;
const errs = validarLocal(campos, valores, firma, fotos, config.plantilla.requiere_firma, config.plantilla.requiere_foto);
if (pideFirma && !firmaNombre.trim()) errs.firma_nombre = 'Escribe el nombre de quien firma';
setErrores(errs);
if (Object.keys(errs).length) {
const primero = document.querySelector('[data-error="1"]');
if (primero) primero.scrollIntoView({ behavior: 'smooth', block: 'center' });
else window.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
setEnviando(true);
setErrorEnvio(null);
try {
// Los campos firma/foto no viajan como respuestas: su contenido va aparte.
const respuestas: Record<string, unknown> = {};
for (const c of campos) {
if (c.tipo === 'firma' || c.tipo === 'foto' || c.tipo === 'archivo') continue;
respuestas[c.clave] = valores[c.clave];
}
const res = await odooApi.llenarFormularioPublico({
token,
respuestas,
firma_imagen: firma || undefined,
firma_nombre: firmaNombre.trim() || undefined,
fotos: fotos.map((f) => ({
campo_clave: f.campo_clave,
kind: f.kind,
name: f.name,
data_url: f.data_url,
})),
hp,
});
if (res.status === 'success') setEnviado(true);
else setErrorEnvio(res.message || 'No se pudo enviar. Intenta de nuevo.');
} catch (err) {
const r = err as { response?: { status?: number; data?: { message?: string } } };
if (r?.response?.status === 409) { setYaLlenado(true); return; }
setErrorEnvio(r?.response?.data?.message || 'No se pudo enviar. Revisa tu conexión e intenta de nuevo.');
} finally {
setEnviando(false);
}
};
if (loading) {
return (
<Centro>
<Loader2 className="h-6 w-6 animate-spin text-theme-muted" />
</Centro>
);
}
if (!config || noDisponible) {
return (
<Centro>
<div className="w-full max-w-sm rounded-3xl border border-theme-border bg-theme-surface p-8 text-center shadow-soft">
<AlertTriangle className="mx-auto mb-3 h-8 w-8 text-amber-500" />
<h1 className="font-heading text-lg text-theme-heading">Formulario no disponible</h1>
<p className="mt-2 text-sm text-theme-muted">
El enlace no es válido o ya expiró. Pide uno nuevo a la clínica.
</p>
</div>
</Centro>
);
}
if (enviado || yaLlenado) {
return (
<Centro>
<div className="w-full max-w-sm rounded-3xl border border-theme-border bg-theme-surface p-8 text-center shadow-soft">
<CheckCircle2 className="mx-auto mb-3 h-10 w-10 text-emerald-500" />
<h1 className="font-heading text-lg text-theme-heading">
¡Gracias{config.paciente?.nombre ? `, ${config.paciente.nombre}` : ''}!
</h1>
<p className="mt-2 text-sm text-theme-muted">
{enviado ? 'Tu formulario quedó registrado.' : 'Este formulario ya había sido llenado.'}
</p>
<p className="mt-4 text-xs text-theme-muted">Ya puedes cerrar esta página.</p>
</div>
</Centro>
);
}
const brand = config.brand;
return (
<div className="min-h-screen bg-theme-bg pb-28">
<header className="sticky top-0 z-10 border-b border-theme-border bg-theme-surface/95 backdrop-blur">
<div className="mx-auto flex max-w-lg items-center gap-3 px-4 py-3">
{(brand.logo_oscuro || brand.logo_claro) && (
<img
src={brand.logo_oscuro || brand.logo_claro}
alt={brand.nombre}
className="h-8 max-w-[120px] object-contain"
/>
)}
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-theme-heading">{brand.nombre}</p>
<p className="truncate text-xs text-theme-muted">{config.plantilla.tipo_label || 'Formulario'}</p>
</div>
</div>
</header>
<main className="mx-auto max-w-lg px-4 py-5">
<h1 className="font-heading text-xl text-theme-heading">{config.plantilla.nombre}</h1>
{config.plantilla.descripcion && (
<p className="mt-1 text-sm text-theme-muted whitespace-pre-wrap">{config.plantilla.descripcion}</p>
)}
{config.cita && (
<p className="mt-2 rounded-xl bg-theme-accent-bg px-3 py-2 text-xs text-theme-heading">
{term('appointment_singular', 'Cita')}
{config.cita.fecha ? ` · ${config.cita.fecha}` : ''}
{config.cita.hora ? ` ${config.cita.hora}` : ''}
{config.cita.servicio ? ` · ${config.cita.servicio}` : ''}
</p>
)}
{config.plantilla.duracion_estimada_min > 0 && (
<p className="mt-1 text-xs text-theme-muted">Tarda unos {config.plantilla.duracion_estimada_min} minutos.</p>
)}
<div className="mt-4">
<FormularioCampos
campos={campos}
valores={valores}
onChange={(clave, v) => setValores((prev) => ({ ...prev, [clave]: v }))}
firma={firma}
onFirma={setFirma}
firmaNombre={firmaNombre}
onFirmaNombre={setFirmaNombre}
fotos={fotos}
setFotos={setFotos}
errores={errores}
textoLegal={config.plantilla.texto_legal}
requiereFirma={config.plantilla.requiere_firma}
requiereFoto={config.plantilla.requiere_foto}
/>
</div>
{/* Honeypot: oculto para personas, un bot lo rellena. */}
<input
type="text"
value={hp}
onChange={(e) => setHp(e.target.value)}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className="absolute left-[-9999px] h-0 w-0 opacity-0"
/>
{errorEnvio && (
<p className="mt-4 rounded-xl bg-rose-50 px-3 py-2 text-sm text-rose-700">{errorEnvio}</p>
)}
{pideFoto && pideFirma && (
<p className="mt-4 text-xs text-theme-muted">
Al enviar aceptas que tus datos y tu firma se guarden en el{' '}
{term('record_singular', 'Expediente').toLowerCase()} de la clínica.
</p>
)}
</main>
<div className="fixed inset-x-0 bottom-0 border-t border-theme-border bg-theme-surface/95 px-4 py-3 backdrop-blur">
<div className="mx-auto max-w-lg">
<button
type="button"
onClick={enviarLlenado}
disabled={enviando}
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-theme-accent px-4 py-4 text-base font-semibold text-theme-inverse disabled:opacity-60"
>
{enviando ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
{enviando ? 'Enviando…' : 'Enviar formulario'}
</button>
</div>
</div>
</div>
);
};
const Centro: FC<{ children: React.ReactNode }> = ({ children }) => (
<div className="flex min-h-screen items-center justify-center bg-theme-bg p-6">{children}</div>
);
export default FormularioPublico;

View File

@@ -3,13 +3,15 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import {
Search, Plus, Activity, ShoppingBag, User, Edit2, Phone, Mail, Calendar, Droplet, Heart,
MapPin, Briefcase, Users, Baby, Stethoscope, FileText, AlertCircle, CheckCircle2, XCircle,
Download, ChevronRight, AlertTriangle, ClipboardList, Camera, Wallet, X,
Download, ChevronRight, AlertTriangle, ClipboardList, Camera, Wallet, X, FileSignature,
} from 'lucide-react';
import Layout from '../components/Layout';
import PatientAdjuntos from '../components/PatientAdjuntos';
import EstadoCuentaPrint from '../components/EstadoCuentaPrint';
import RecargarSaldoModal from '../components/RecargarSaldoModal';
import ReciboPrint from '../components/ReciboPrint';
import FormulariosPaciente from '../components/FormulariosPaciente';
import EnviarFormularioModal from '../components/formulario/EnviarFormularioModal';
import { usePatientDrawer } from '../components/PatientDrawer';
import {
Card,
@@ -144,10 +146,12 @@ const Pacientes: FC = () => {
const [recargaOpen, setRecargaOpen] = useState(false);
const [reciboDeposito, setReciboDeposito] = useState<ReceiptData | null>(null);
const [historyLoading, setHistoryLoading] = useState(false);
const [tab, setTab] = useState<'info' | 'clinical' | 'timeline' | 'docs'>('info');
const [tab, setTab] = useState<'info' | 'clinical' | 'timeline' | 'docs' | 'formularios'>('info');
const [entryDetail, setEntryDetail] = useState<
{ kind: 'cita'; item: Appointment } | { kind: 'venta'; item: Sale } | { kind: 'visita'; item: Visita } | null
>(null);
// Envío de formulario desde el detalle de la cita del historial
const [enviarFormularioCita, setEnviarFormularioCita] = useState<{ patientId: number; patientName: string; citaId: number } | null>(null);
const [exporting, setExporting] = useState(false);
const [onlyIncomplete, setOnlyIncomplete] = useState(false);
const [onlyVip, setOnlyVip] = useState(false);
@@ -1002,6 +1006,7 @@ const Pacientes: FC = () => {
{ key: 'clinical', label: 'Historia clínica' },
{ key: 'timeline', label: 'Historial' },
{ key: 'docs', label: 'Documentos' },
{ key: 'formularios', label: 'Formularios' },
] as const).map(({ key, label }) => (
<button
key={key}
@@ -1192,6 +1197,10 @@ const Pacientes: FC = () => {
</>
)}
</div>
) : tab === 'formularios' ? (
selected ? (
<FormulariosPaciente patientId={selected.id} patientName={selected.name} />
) : null
) : (
timelineItems.length > 0 ? (
<div className="space-y-2">
@@ -1253,6 +1262,20 @@ const Pacientes: FC = () => {
onClose={() => setEntryDetail(null)}
title={entryDetail?.kind === 'cita' ? 'Detalle de la cita' : entryDetail?.kind === 'visita' ? 'Detalle de la visita' : 'Detalle de la venta'}
maxWidth="lg"
footer={
entryDetail?.kind === 'cita' && can('formularios.enviar') ? (
<Button
variant="outline"
onClick={() => setEnviarFormularioCita({
patientId: entryDetail.item.patient_id,
patientName: entryDetail.item.patient,
citaId: entryDetail.item.id,
})}
>
<FileSignature size={16} className="mr-2" /> Enviar formulario
</Button>
) : undefined
}
>
{entryDetail?.kind === 'cita' && (
<div className="space-y-4">
@@ -1399,6 +1422,16 @@ const Pacientes: FC = () => {
}}
/>
<ReciboPrint receipt={reciboDeposito} onClose={() => setReciboDeposito(null)} />
<EnviarFormularioModal
isOpen={!!enviarFormularioCita}
onClose={() => setEnviarFormularioCita(null)}
patientId={enviarFormularioCita?.patientId ?? null}
patientName={enviarFormularioCita?.patientName}
citaId={enviarFormularioCita?.citaId ?? null}
onSent={() => {
if (selected?.id) setTab('formularios');
}}
/>
</Layout>
);
};

View File

@@ -1071,6 +1071,8 @@ export interface NotificacionesSettings {
plantilla_confirmacion: string;
plantilla_cumpleanos: string;
plantilla_post_visita: string;
/** Aviso de un formulario digital: usa {formulario} y {enlace}. */
plantilla_formulario: string;
whatsapp_numero: string;
email_remitente: string;
}
@@ -1089,12 +1091,13 @@ export const NOTIFICACIONES_DEFAULT: NotificacionesSettings = {
plantilla_confirmacion: 'Hola {nombre}, tu {cita} de {servicio} quedó registrada para el {fecha} a las {hora} con {doctor} en {sucursal}. Te esperamos.',
plantilla_cumpleanos: '¡Feliz cumpleaños, {nombre}! Todo el equipo de {sucursal} te desea un gran día. Te esperamos pronto.',
plantilla_post_visita: 'Hola {nombre}, gracias por tu visita de {servicio} el {fecha}. Si tienes dudas o quieres agendar de nuevo, llámanos al {telefono}.',
plantilla_formulario: 'Hola {nombre}, te compartimos el formulario "{formulario}" para completar. Ábrelo desde tu celular aquí: {enlace}',
whatsapp_numero: '',
email_remitente: '',
};
/** Variables permitidas en las plantillas (espejo del backend). */
export const NOTIF_VARIABLES = ['nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor', 'sucursal', 'telefono'] as const;
export const NOTIF_VARIABLES = ['nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor', 'sucursal', 'telefono', 'enlace', 'formulario'] as const;
/** Ventanas sugeridas para los chips de recordatorio. */
export const NOTIF_VENTANAS: { horas: number; label: string }[] = [
@@ -1224,6 +1227,114 @@ export interface BookingCita {
telefono_contacto: string;
}
// ---- Formularios digitales y consentimientos ----
export type FormularioCampoTipo =
| 'texto' | 'texto_largo' | 'numero' | 'fecha' | 'seleccion' | 'multi_seleccion'
| 'checkbox' | 'escala' | 'firma' | 'foto' | 'archivo' | 'consentimiento';
export type FormularioPlantillaTipo = 'consentimiento' | 'expediente' | 'encuesta' | 'autorizacion';
export type FormularioEstado = 'borrador' | 'enviado' | 'llenado' | 'cancelado';
export type FormularioMomento = 'antes' | 'despues' | 'otro';
export type FormularioCanal = 'whatsapp' | 'email' | 'enlace';
export interface FormularioCampo {
clave: string;
etiqueta: string;
tipo: FormularioCampoTipo;
obligatorio: boolean;
ayuda: string;
opciones: string[];
/** Solo para tipo foto/archivo: para la galería antes/después del expediente. */
momento?: FormularioMomento;
}
export interface FormularioPlantilla {
id: number;
name: string;
tipo: FormularioPlantillaTipo;
tipo_label?: string;
activo: boolean;
version: number;
descripcion: string;
campos: FormularioCampo[];
texto_legal: string;
requiere_firma: boolean;
requiere_foto: boolean;
duracion_estimada_min: number;
secuencia: number;
respuestas_count: number;
sucursales?: { id: number; name: string }[];
servicios?: { id: number; name: string }[];
}
export interface FormularioAdjunto {
id: number;
campo_clave: string;
kind: FormularioMomento;
kind_label?: string;
name: string;
mimetype: string;
notes: string;
create_date: string | null;
url: string;
}
export interface FormularioFoto extends FormularioAdjunto {
respuesta_id: number;
respuesta_name: string;
plantilla: string;
cita_id: number | null;
}
export interface FormularioRespuesta {
id: number;
name: string;
plantilla_id: number;
plantilla: string;
plantilla_tipo?: FormularioPlantillaTipo;
plantilla_version: number;
partner_id: number;
patient: string;
cita_id: number | null;
estado: FormularioEstado;
estado_label: string;
canal_envio: string;
creado_por_empleado: boolean;
tiene_firma: boolean;
firma_nombre: string;
enviado_at: string | null;
llenado_at: string | null;
create_date: string | null;
fotos_count: number;
// Solo en el detalle
respuestas?: Record<string, unknown>;
campos?: FormularioCampo[];
texto_legal?: string;
notas?: string;
firma_url?: string | null;
fotos?: FormularioAdjunto[];
}
export interface FormularioPublicoConfig {
status: string;
brand: { nombre: string; logo_claro: string; logo_oscuro: string; colores: { primario: string; acento: string } };
terms: GlossaryTerms;
paciente: { nombre: string };
cita: { fecha: string | null; hora: string | null; servicio: string | null } | null;
estado: FormularioEstado;
ya_llenado: boolean;
plantilla: {
nombre: string;
tipo: FormularioPlantillaTipo;
tipo_label?: string;
descripcion: string;
texto_legal: string;
requiere_firma: boolean;
requiere_foto: boolean;
duracion_estimada_min: number;
campos: FormularioCampo[];
};
}
export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
nombre: BRAND_DEFAULT.nombre,
telefono: '',
@@ -1926,6 +2037,88 @@ export const odooApi = {
return data;
},
// ---- Formularios digitales: plantillas ----
async getFormularioPlantillas(opts?: { includeInactive?: boolean; tipo?: string }): Promise<{ status: string; plantillas: FormularioPlantilla[]; total: number; message?: string }> {
const params: Record<string, string | number> = {};
if (opts?.includeInactive) params.include_inactive = 1;
if (opts?.tipo) params.tipo = opts.tipo;
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/formularios/plantillas`, { params });
return data;
},
async getFormularioPlantilla(id: number): Promise<{ status: string; plantilla: FormularioPlantilla; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/formularios/plantillas/${id}`);
return data;
},
async createFormularioPlantilla(payload: Partial<FormularioPlantilla> & { sucursal_ids?: number[]; servicio_ids?: number[] }): Promise<{ status: string; plantilla: FormularioPlantilla; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/formularios/plantillas`, payload);
return data;
},
async updateFormularioPlantilla(id: number, payload: Partial<FormularioPlantilla> & { sucursal_ids?: number[]; servicio_ids?: number[] }): Promise<{ status: string; plantilla: FormularioPlantilla; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/formularios/plantillas/${id}`, payload);
return data;
},
async deleteFormularioPlantilla(id: number): Promise<{ status: string; desactivada: boolean; message: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/formularios/plantillas/${id}`);
return data;
},
// ---- Formularios digitales: respuestas ----
async getFormularioRespuestas(params?: Record<string, string | number>): Promise<{ status: string; respuestas: FormularioRespuesta[]; total: number; page: number; page_size: number; total_pages: number; resumen: Record<string, number>; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/formularios/respuestas`, { params });
return data;
},
async getFormularioRespuesta(id: number): Promise<{ status: string; respuesta: FormularioRespuesta; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/formularios/respuestas/${id}`);
return data;
},
async getFormularioFotos(params: { partner_id?: number; respuesta_id?: number }): Promise<{ status: string; total: number; fotos: FormularioFoto[]; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/formularios/fotos`, { params });
return data;
},
async enviarFormulario(payload: { plantilla_id: number; partner_id: number; cita_id?: number | null; canal: FormularioCanal }): Promise<{ status: string; respuesta: FormularioRespuesta; token: string; enlace: string; enviado_por: string; notificado: boolean; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/formularios/enviar`, payload);
return data;
},
async llenarFormulario(id: number, payload: { respuestas: Record<string, unknown>; firma_imagen?: string; firma_nombre?: string }): Promise<{ status: string; respuesta: FormularioRespuesta; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/formularios/respuestas/${id}/llenar`, payload);
return data;
},
async adjuntarFormulario(payload: { respuesta_id: number; campo_clave?: string; kind?: FormularioMomento; name?: string; file: string }): Promise<{ status: string; adjunto: FormularioAdjunto; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/formularios/adjuntar`, payload);
return data;
},
// URL del binario (firma/foto) con el token de sesión en query para <img src>
formularioArchivoUrl(url: string): string {
const token = localStorage.getItem('skeen_token') || '';
return `${ODOO_BASE}${url}?token=${encodeURIComponent(token)}`;
},
// ---- Portal público de formularios (sin autenticación) ----
async getFormularioPublicoConfig(token: string): Promise<FormularioPublicoConfig> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/public/formulario/config`, { params: { token } });
return data;
},
async getFormularioPublicoEstado(token: string): Promise<{ status: string; estado: FormularioEstado; ya_llenado: boolean; paciente: { nombre: string }; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/public/formulario`, { params: { token } });
return data;
},
async llenarFormularioPublico(payload: { token: string; respuestas: Record<string, unknown>; firma_imagen?: string; firma_nombre?: string; fotos?: { campo_clave?: string; kind?: FormularioMomento; name?: string; data_url: string }[]; hp?: string }): Promise<{ status: string; message: string; errores?: string[] }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/public/formulario/llenar`, payload);
return data;
},
// ---- Notificaciones y recordatorios de citas ----
async getNotificaciones(params?: Record<string, string | number>): Promise<NotificacionesResponse> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/notificaciones`, { params });