From 9286ab8ee2e84b47c5263103e8c8183beab56ab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Sun, 11 Oct 2026 09:48:28 +0000 Subject: [PATCH] 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) --- frontend/src/App.tsx | 3 + .../src/components/FormulariosPaciente.tsx | 264 ++++++++++ frontend/src/components/FormulariosPanel.tsx | 438 ++++++++++++++++ .../src/components/NotificacionesPanel.tsx | 3 + .../components/formulario/CampoFormulario.tsx | 354 +++++++++++++ .../formulario/EnviarFormularioModal.tsx | 170 ++++++ .../src/components/formulario/FirmaCanvas.tsx | 124 +++++ .../formulario/FormularioCampos.tsx | 140 +++++ frontend/src/components/formulario/tipos.ts | 122 +++++ frontend/src/lib/permissions.ts | 6 + frontend/src/pages/Agenda.tsx | 17 +- frontend/src/pages/Configuracion.tsx | 11 +- frontend/src/pages/FormularioPublico.tsx | 241 +++++++++ frontend/src/pages/Pacientes.tsx | 37 +- frontend/src/services/odoo.ts | 195 ++++++- odoo-addons/skeen_citas/clinic_settings.py | 9 +- odoo-addons/skeen_formularios/__init__.py | 4 + odoo-addons/skeen_formularios/__manifest__.py | 26 + .../skeen_formularios/controllers/__init__.py | 5 + .../skeen_formularios/controllers/comun.py | 323 ++++++++++++ .../controllers/formularios.py | 496 ++++++++++++++++++ .../controllers/public_formulario.py | 273 ++++++++++ .../skeen_formularios/data/sequences.xml | 14 + .../skeen_formularios/models/__init__.py | 5 + .../models/formulario_adjunto.py | 32 ++ .../models/formulario_plantilla.py | 139 +++++ .../models/formulario_respuesta.py | 68 +++ .../security/ir.model.access.csv | 4 + .../skeen_whatsapp/models/notificacion.py | 21 +- odoo-addons/skeen_whatsapp/permissions.py | 13 + 30 files changed, 3545 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/FormulariosPaciente.tsx create mode 100644 frontend/src/components/FormulariosPanel.tsx create mode 100644 frontend/src/components/formulario/CampoFormulario.tsx create mode 100644 frontend/src/components/formulario/EnviarFormularioModal.tsx create mode 100644 frontend/src/components/formulario/FirmaCanvas.tsx create mode 100644 frontend/src/components/formulario/FormularioCampos.tsx create mode 100644 frontend/src/components/formulario/tipos.ts create mode 100644 frontend/src/pages/FormularioPublico.tsx create mode 100644 odoo-addons/skeen_formularios/__init__.py create mode 100644 odoo-addons/skeen_formularios/__manifest__.py create mode 100644 odoo-addons/skeen_formularios/controllers/__init__.py create mode 100644 odoo-addons/skeen_formularios/controllers/comun.py create mode 100644 odoo-addons/skeen_formularios/controllers/formularios.py create mode 100644 odoo-addons/skeen_formularios/controllers/public_formulario.py create mode 100644 odoo-addons/skeen_formularios/data/sequences.xml create mode 100644 odoo-addons/skeen_formularios/models/__init__.py create mode 100644 odoo-addons/skeen_formularios/models/formulario_adjunto.py create mode 100644 odoo-addons/skeen_formularios/models/formulario_plantilla.py create mode 100644 odoo-addons/skeen_formularios/models/formulario_respuesta.py create mode 100644 odoo-addons/skeen_formularios/security/ir.model.access.csv diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6beb7d9..2f6b27e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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. */} } /> } /> + {/* Portal público de formularios: token HMAC como credencial. */} + } /> } /> } /> } /> diff --git a/frontend/src/components/FormulariosPaciente.tsx b/frontend/src/components/FormulariosPaciente.tsx new file mode 100644 index 0000000..949578f --- /dev/null +++ b/frontend/src/components/FormulariosPaciente.tsx @@ -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 = ({ patientId, patientName = '' }) => { + const { can } = useAuth(); + const { term } = useGlosario(); + const [respuestas, setRespuestas] = useState([]); + const [fotos, setFotos] = useState([]); + const [loading, setLoading] = useState(true); + const [detalle, setDetalle] = useState(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(); + 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 ( +
+
+

+ Formularios y consentimientos +

+ {can('formularios.enviar') && ( + + )} +
+ + {pendientes.length > 0 && ( +
+

+ {pendientes.length} formulario{pendientes.length > 1 ? 's' : ''} pendiente{pendientes.length > 1 ? 's' : ''} de llenar +

+
    + {pendientes.map((r) => ( +
  • + {r.plantilla} · enviado {fechaCorta(r.enviado_at || r.create_date)} +
  • + ))} +
+
+ )} + + {loading ? ( + + ) : respuestas.length === 0 ? ( + } + /> + ) : ( +
    + {respuestas.map((r) => ( +
  • + +
  • + ))} +
+ )} + + {galeria.length > 0 && ( +
+

+ Galería antes / después +

+
+ {galeria.map(([dia, items]) => ( +
+

{fechaCorta(dia)}

+
+ {items.map((f) => ( +
+ {f.name} +
+ {MOMENTO_LABEL[f.kind] || f.kind} +
+
+ ))} +
+
+ ))} +
+
+ )} + + setDetalle(null)} + title={detalle ? `${detalle.plantilla} — ${detalle.name}` : 'Formulario'} + maxWidth="2xl" + > + {detalle && ( +
+
+ {detalle.estado_label || detalle.estado} + {fechaCorta(detalle.llenado_at || detalle.enviado_at || detalle.create_date)} + · {detalle.creado_por_empleado ? 'Llenado por el personal' : `Llenado por el ${term('patient_singular').toLowerCase()}`} +
+ + {(detalle.campos || []).length > 0 && ( +
+ {(detalle.campos || []) + .filter((c) => !['firma', 'foto', 'archivo'].includes(c.tipo)) + .map((c) => ( +
+

{c.etiqueta}

+

+ {valorLegible(c, detalle.respuestas?.[c.clave])} +

+
+ ))} +
+ )} + + {detalle.texto_legal && ( +
+ Texto legal aceptado +

{detalle.texto_legal}

+
+ )} + + {detalle.tiene_firma && detalle.firma_url && ( +
+

Firma{detalle.firma_nombre ? ` de ${detalle.firma_nombre}` : ''}

+ Firma +
+ )} + + {(detalle.fotos || []).length > 0 && ( +
+

Fotos y archivos

+
+ {(detalle.fotos || []).map((f) => ( +
+ {f.mimetype.startsWith('image/') ? ( + {f.name} + ) : ( + + {f.name} + + )} +
+ {MOMENTO_LABEL[f.kind] || f.kind} +
+
+ ))} +
+
+ )} +
+ )} +
+ + setEnviarOpen(false)} + patientId={patientId} + patientName={patientName} + onSent={cargar} + /> +
+ ); +}; + +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()}. +

+
+
+ {can('formularios.enviar') && ( + + )} + {can('formularios.crear') && ( + + )} +
+
+ + {loading ? ( + + ) : plantillas.length === 0 ? ( + + + } + actionLabel={can('formularios.crear') ? 'Nueva plantilla' : undefined} + onAction={can('formularios.crear') ? () => setEditando('nueva') : undefined} + /> + + + ) : ( +
+ + + + + + + + + + + + {plantillas.map((p) => ( + + + + + + + + ))} + +
PlantillaTipoCamposEstadoAcciones
+

{p.name}

+

v{p.version} · {p.respuestas_count} respuesta{p.respuestas_count === 1 ? '' : 's'}

+
{p.tipo_label || p.tipo}{p.campos.length} + {p.activo ? 'Activa' : 'Inactiva'} + +
+ {can('formularios.editar') && ( + <> + + + + + )} +
+
+
+ )} + + {editando && ( + setEditando(null)} + onSaved={() => { setEditando(null); cargar(); }} + /> + )} + + setEnviarOpen(false)} onSent={cargar} /> +
+ ); +}; + +// --------------------------------------------------------------------------- + +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(plantilla?.campos || []); + const [guardando, setGuardando] = useState(false); + const [preview, setPreview] = useState(false); + const previewValores = useMemo(() => ({}), []); + + const actualizar = (i: number, patch: Partial) => + 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 ( + + + + {canSave && ( + + )} + + } + > +
+
+ setName(e.target.value)} placeholder="Consentimiento de láser" /> +