+ );
+};
+
+export default FormulariosPaciente;
diff --git a/frontend/src/components/FormulariosPanel.tsx b/frontend/src/components/FormulariosPanel.tsx
new file mode 100644
index 0000000..5d1c34a
--- /dev/null
+++ b/frontend/src/components/FormulariosPanel.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(true);
+ const [editando, setEditando] = useState(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 (
+
+
+
+
+
Formularios digitales
+
+ 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()}.
+
+ );
+};
+
+export default CampoFormulario;
diff --git a/frontend/src/components/formulario/EnviarFormularioModal.tsx b/frontend/src/components/formulario/EnviarFormularioModal.tsx
new file mode 100644
index 0000000..c941202
--- /dev/null
+++ b/frontend/src/components/formulario/EnviarFormularioModal.tsx
@@ -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 = ({ isOpen, onClose, patientId = null, patientName = '', citaId = null, onSent }) => {
+ const settings = useClinicSettings();
+ const { term } = useGlosario();
+ const [plantillas, setPlantillas] = useState([]);
+ const [plantillaId, setPlantillaId] = useState('');
+ const [paciente, setPaciente] = useState(patientId ? String(patientId) : '');
+ const [canal, setCanal] = useState('enlace');
+ const [enviando, setEnviando] = useState(false);
+ const [enlace, setEnlace] = useState(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 (
+ Cerrar
+ ) : (
+ <>
+
+
+ >
+ )
+ }
+ >
+ {plantillas.length === 0 ? (
+
+ Aún no hay plantillas de formulario. Crea una en Configuración › Formularios.
+
+ ) : enlace ? (
+
+
+ El formulario quedó pendiente. Comparte esta liga con {patientName || `el ${term('patient_singular').toLowerCase()}`}:
+
+
+
+
+
+
+ {canal !== 'enlace' && (
+
+ {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.'}
+
+ )}
+
+ ) : (
+
+
+ )}
+
+ );
+};
+
+export default EnviarFormularioModal;
diff --git a/frontend/src/components/formulario/FirmaCanvas.tsx b/frontend/src/components/formulario/FirmaCanvas.tsx
new file mode 100644
index 0000000..6df6034
--- /dev/null
+++ b/frontend/src/components/formulario/FirmaCanvas.tsx
@@ -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 = ({ value, onChange, disabled = false, height = 200 }) => {
+ const canvasRef = useRef(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) => {
+ const rect = e.currentTarget.getBoundingClientRect();
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
+ };
+
+ const empezar = (e: React.PointerEvent) => {
+ 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) => {
+ 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 (
+
+
+
+ {vacio && !disabled && (
+
+
+ Firma aquí con el dedo
+
+ )}
+
+ {!disabled && (
+
+ Toca y arrastra para firmar
+
+
+ )}
+
+ );
+};
+
+export default FirmaCanvas;
diff --git a/frontend/src/components/formulario/FormularioCampos.tsx b/frontend/src/components/formulario/FormularioCampos.tsx
new file mode 100644
index 0000000..b9b6edc
--- /dev/null
+++ b/frontend/src/components/formulario/FormularioCampos.tsx
@@ -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;
+ 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;
+ disabled?: boolean;
+ textoLegal?: string;
+ requiereFirma?: boolean;
+ requiereFoto?: boolean;
+}
+
+const FormularioCampos: FC = ({
+ 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 (
+