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 });

View File

@@ -312,15 +312,20 @@ NOTIFICACIONES_DEFAULT = {
'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': '',
}
NOTIF_CANALES = ('whatsapp', 'email', 'ambos')
NOTIF_VARIABLES = ('nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor',
'sucursal', 'telefono')
'sucursal', 'telefono', 'enlace', 'formulario')
NOTIF_PLANTILLAS = ('plantilla_recordatorio', 'plantilla_confirmacion',
'plantilla_cumpleanos', 'plantilla_post_visita')
'plantilla_cumpleanos', 'plantilla_post_visita',
'plantilla_formulario')
NOTIF_HORAS_MAX = 168 # 1 semana
NOTIF_PLANTILLA_MAX = 1000 # caracteres por plantilla
NOTIF_TEXTO_MAX = 120 # whatsapp_numero / email_remitente

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
{
'name': 'SKEEN Formularios',
'version': '1.0.0',
'category': 'Healthcare',
'summary': 'Formularios digitales y consentimientos informados para SKEEN',
'description': """
Constructor de plantillas de formularios (consentimientos, encuestas,
autorizaciones y notas de expediente) que el paciente llena desde su
celular con firma y fotos, o que el personal llena en recepción.
Sin plantillas creadas el módulo no expone nada: ni la API pública
responde, ni el expediente muestra secciones nuevas.
""",
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
'website': 'https://skeen.mx',
'depends': ['base', 'skeen_citas', 'skeen_visitas', 'skeen_whatsapp'],
'data': [
'security/ir.model.access.csv',
'data/sequences.xml',
],
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import comun
from . import formularios
from . import public_formulario

View File

@@ -0,0 +1,323 @@
# -*- coding: utf-8 -*-
"""Utilidades compartidas por los controladores de formularios.
Aquí vive lo que no debe duplicarse entre la API autenticada y el portal
público: firma/verificación del token HMAC, validación de las respuestas contra
la plantilla, lectura acotada de imágenes en data URL y los serializadores.
"""
import base64
import binascii
import hashlib
import hmac
import io
import json
import time
from PIL import Image
from odoo.addons.skeen_whatsapp.controllers.auth import _get_secret
# El token de un formulario viaja en un enlace que el paciente puede abrir
# días después: mismo TTL que el portal de reservas (180 días).
FORM_TOKEN_TTL = 60 * 60 * 24 * 180
FORM_TOKEN_KIND = 'formulario'
# Límites de subida. Las fotos de celular pesan; se acotan por foto y en número.
MAX_FOTO_BYTES = 5 * 1024 * 1024
MAX_FIRMA_BYTES = 3 * 1024 * 1024
MAX_FOTOS = 10
# Solo formatos que el navegador produce de forma habitual y que PIL sabe leer
# (así una imagen dañada se rechaza con 400 y no revienta el guardado).
MIMES_FOTO = ('image/jpeg', 'image/png', 'image/webp')
MAX_TEXTO_RESP = 5000
def _b64url_encode(raw):
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
def _b64url_decode(s):
return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))
def firmar_token(env, respuesta):
"""Token HMAC de una respuesta (mismo formato que el portal de reservas)."""
payload = {'rid': respuesta.id, 'kind': FORM_TOKEN_KIND,
'exp': int(time.time()) + FORM_TOKEN_TTL}
body = _b64url_encode(json.dumps(payload, separators=(',', ':')).encode('utf-8'))
sig = hmac.new(_get_secret(env).encode('utf-8'),
body.encode('ascii'), hashlib.sha256).hexdigest()
return '%s.%s' % (body, sig)
def verificar_token(env, token):
"""Payload del token o None si es inválido, de otro tipo o expiró."""
if not token or '.' not in str(token):
return None
try:
body, sig = str(token).rsplit('.', 1)
expected = hmac.new(_get_secret(env).encode('utf-8'),
body.encode('ascii'), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
return None
payload = json.loads(_b64url_decode(body).decode('utf-8'))
except Exception:
return None
if payload.get('kind') != FORM_TOKEN_KIND:
return None
if int(payload.get('exp', 0)) < int(time.time()):
return None
return payload
def respuesta_de_token(env, token):
"""Respuesta vinculada al token, o recordset vacío."""
payload = verificar_token(env, token)
if not payload:
return env['skeen.formulario.respuesta'].sudo().browse()
respuesta = env['skeen.formulario.respuesta'].sudo().browse(int(payload.get('rid') or 0))
return respuesta if respuesta.exists() else env['skeen.formulario.respuesta'].sudo().browse()
def parse_data_url(valor, max_bytes, mimes_ok):
"""Extrae (base64_limpio, mimetype) de un data URL. Lanza ValueError.
Acepta también base64 plano (sin encabezado) asumiendo el primer mime
permitido. Valida el tamaño ya decodificado y que la imagen se pueda leer:
una imagen dañada se rechaza aquí con 400 en vez de reventar el guardado
(Odoo reprocesa las imágenes al escribir un Binary como adjunto).
"""
if not valor or not isinstance(valor, str):
raise ValueError('Imagen vacía')
raw = valor.strip()
mimetype = mimes_ok[0] if mimes_ok else 'application/octet-stream'
b64 = raw
if raw.startswith('data:'):
try:
cabecera, b64 = raw.split(',', 1)
except ValueError:
raise ValueError('Formato de imagen no válido')
if ';base64' not in cabecera:
raise ValueError('La imagen debe venir en base64')
tipo = cabecera[5:].split(';')[0].strip().lower()
if mimes_ok and tipo not in mimes_ok:
raise ValueError('Tipo de imagen no permitido: %s' % (tipo or 'desconocido'))
mimetype = tipo or mimetype
b64 = ''.join(b64.split())
try:
crudo = base64.b64decode(b64, validate=True)
except (binascii.Error, ValueError):
raise ValueError('La imagen no es base64 válido')
if not crudo:
raise ValueError('La imagen está vacía')
if len(crudo) > max_bytes:
raise ValueError('La imagen excede el tamaño máximo permitido')
_validar_imagen(crudo)
return b64, mimetype
def _validar_imagen(crudo):
"""Comprueba que los bytes son una imagen legible. Lanza ValueError si no."""
try:
with Image.open(io.BytesIO(crudo)) as img:
img.verify()
with Image.open(io.BytesIO(crudo)) as img:
img.load()
except Exception:
raise ValueError('La imagen no se pudo leer (archivo dañado o formato no soportado)')
def _texto(valor, limite=MAX_TEXTO_RESP):
return str(valor)[:limite]
def limpiar_respuestas(campos, respuestas):
"""Deja solo las claves conocidas de la plantilla, con su valor normalizado."""
respuestas = respuestas if isinstance(respuestas, dict) else {}
out = {}
for campo in campos:
clave = campo['clave']
if clave not in respuestas:
continue
valor = respuestas[clave]
tipo = campo['tipo']
if tipo in ('texto', 'texto_largo'):
out[clave] = _texto(valor)
elif tipo == 'numero':
try:
out[clave] = float(valor)
except (TypeError, ValueError):
out[clave] = None
elif tipo == 'fecha':
out[clave] = _texto(valor, 20)
elif tipo == 'seleccion':
out[clave] = _texto(valor, 300)
elif tipo == 'multi_seleccion':
out[clave] = [_texto(v, 300) for v in valor] if isinstance(valor, list) else []
elif tipo in ('checkbox', 'consentimiento'):
out[clave] = bool(valor)
elif tipo == 'escala':
try:
out[clave] = int(valor)
except (TypeError, ValueError):
out[clave] = None
elif tipo in ('firma', 'foto', 'archivo'):
out[clave] = bool(valor)
else:
out[clave] = _texto(valor)
return out
def validar_respuestas(plantilla, campos, respuestas, fotos_claves,
tiene_firma, requiere_firma, requiere_foto):
"""Errores de validación de un llenado (lista de mensajes claros)."""
errores = []
respuestas = respuestas if isinstance(respuestas, dict) else {}
fotos_claves = set(fotos_claves or [])
for campo in campos:
clave = campo['clave']
etiqueta = campo.get('etiqueta') or clave
tipo = campo['tipo']
obligatorio = bool(campo.get('obligatorio'))
valor = respuestas.get(clave)
if tipo == 'consentimiento':
if obligatorio and not valor:
errores.append('Debes aceptar: %s' % etiqueta)
continue
if tipo == 'checkbox':
continue
if tipo == 'firma':
if obligatorio and not tiene_firma:
errores.append('Falta la firma: %s' % etiqueta)
continue
if tipo in ('foto', 'archivo'):
if obligatorio and clave not in fotos_claves:
errores.append('Falta adjuntar: %s' % etiqueta)
continue
if tipo == 'escala':
if valor in (None, ''):
if obligatorio:
errores.append('Falta responder: %s' % etiqueta)
else:
try:
n = int(valor)
if not (1 <= n <= 10):
errores.append('%s debe estar entre 1 y 10' % etiqueta)
except (TypeError, ValueError):
errores.append('%s debe ser un número del 1 al 10' % etiqueta)
continue
if tipo == 'numero':
if valor in (None, ''):
if obligatorio:
errores.append('Falta responder: %s' % etiqueta)
else:
try:
float(valor)
except (TypeError, ValueError):
errores.append('%s debe ser un número' % etiqueta)
continue
if tipo == 'multi_seleccion':
opciones = campo.get('opciones') or []
if valor in (None, '', []):
if obligatorio:
errores.append('Selecciona al menos una opción en: %s' % etiqueta)
elif not isinstance(valor, list):
errores.append('%s debe ser una lista de opciones' % etiqueta)
else:
invalidas = [v for v in valor if v not in opciones]
if invalidas:
errores.append('%s tiene opciones no válidas' % etiqueta)
continue
if tipo == 'seleccion':
opciones = campo.get('opciones') or []
if valor in (None, ''):
if obligatorio:
errores.append('Falta elegir: %s' % etiqueta)
elif valor not in opciones:
errores.append('%s tiene una opción no válida' % etiqueta)
continue
# texto, texto_largo, fecha
if obligatorio and (valor is None or str(valor).strip() == ''):
errores.append('Falta responder: %s' % etiqueta)
if requiere_firma and not tiene_firma:
errores.append('Este formulario requiere firma')
if requiere_foto and not fotos_claves:
errores.append('Este formulario requiere al menos una foto')
return errores
def plantilla_dict(plantilla, public=False):
data = {
'id': plantilla.id,
'name': plantilla.name,
'tipo': plantilla.tipo,
'tipo_label': dict(plantilla._fields['tipo'].selection).get(plantilla.tipo),
'activo': plantilla.activo,
'version': plantilla.version,
'descripcion': plantilla.descripcion or '',
'campos': plantilla.campos_lista(),
'texto_legal': plantilla.texto_legal or '',
'requiere_firma': plantilla.requiere_firma,
'requiere_foto': plantilla.requiere_foto,
'duracion_estimada_min': plantilla.duracion_estimada_min,
'secuencia': plantilla.secuencia,
'respuestas_count': plantilla.respuestas_count,
}
if not public:
data['sucursales'] = [{'id': s.id, 'name': s.name} for s in plantilla.sucursales]
data['servicios'] = [{'id': s.id, 'name': s.name} for s in plantilla.servicios]
return data
def adjunto_dict(adj):
return {
'id': adj.id,
'campo_clave': adj.campo_clave or '',
'kind': adj.kind or 'otro',
'kind_label': dict(adj._fields['kind'].selection).get(adj.kind),
'name': adj.name or '',
'mimetype': adj.mimetype or '',
'notes': adj.notes or '',
'create_date': adj.create_date.strftime('%Y-%m-%d %H:%M') if adj.create_date else None,
'url': '/skeen/frontend/v1/formularios/adjuntos/%s/file' % adj.id,
}
def respuesta_dict(respuesta, detalle=False):
data = {
'id': respuesta.id,
'name': respuesta.name,
'plantilla_id': respuesta.plantilla_id.id,
'plantilla': respuesta.plantilla_id.name,
'plantilla_tipo': respuesta.plantilla_id.tipo,
'plantilla_version': respuesta.plantilla_version,
'partner_id': respuesta.partner_id.id,
'patient': respuesta.partner_id.name,
'cita_id': respuesta.cita_id.id if respuesta.cita_id else None,
'estado': respuesta.estado,
'estado_label': dict(respuesta._fields['estado'].selection).get(respuesta.estado),
'canal_envio': respuesta.canal_envio,
'creado_por_empleado': respuesta.creado_por_empleado,
'tiene_firma': bool(respuesta.firma_imagen),
'firma_nombre': respuesta.firma_nombre or '',
'enviado_at': respuesta.enviado_at.strftime('%Y-%m-%d %H:%M') if respuesta.enviado_at else None,
'llenado_at': respuesta.llenado_at.strftime('%Y-%m-%d %H:%M') if respuesta.llenado_at else None,
'create_date': respuesta.create_date.strftime('%Y-%m-%d %H:%M') if respuesta.create_date else None,
'fotos_count': len(respuesta.foto_ids),
}
if detalle:
try:
data['respuestas'] = json.loads(respuesta.respuestas or '{}')
except (ValueError, TypeError):
data['respuestas'] = {}
data['campos'] = respuesta.plantilla_id.campos_lista()
data['texto_legal'] = respuesta.plantilla_id.texto_legal or ''
data['notas'] = respuesta.notas or ''
data['firma_url'] = ('/skeen/frontend/v1/formularios/respuestas/%s/firma' % respuesta.id
if respuesta.firma_imagen else None)
data['fotos'] = [adjunto_dict(a) for a in respuesta.foto_ids]
return data

View File

@@ -0,0 +1,496 @@
# -*- coding: utf-8 -*-
"""API autenticada de formularios digitales (panel de la clínica).
Rutas bajo `/skeen/frontend/v1/formularios/*`. Mismo guard doble que el resto
del frontend: `@require_role` para el rango del rol y `@require_perm` para la
acción concreta del catálogo (`formularios.ver|crear|editar|enviar`).
Los binarios (firma y fotos) se sirven por rutas propias que aceptan el token
de sesión por header o por query string, para poder usarlos en `<img src>`.
"""
import base64
import json
import logging
from odoo import fields, http
from odoo.http import request, Response
from odoo.addons.skeen_whatsapp.controllers.auth import (
_json, current_user, require_role, require_perm,
verify_token as verify_session_token,
)
from odoo.addons.skeen_citas.clinic_settings import get_notificaciones, url_publica
from .comun import (
firmar_token, plantilla_dict, respuesta_dict, adjunto_dict,
limpiar_respuestas, validar_respuestas,
parse_data_url, MAX_FOTO_BYTES, MAX_FIRMA_BYTES, MIMES_FOTO,
)
from ..models.formulario_plantilla import normalizar_campos
PREFIX = '/skeen/frontend/v1/formularios'
def _body():
try:
return json.loads(request.httprequest.get_data(as_text=True) or '{}')
except Exception:
return {}
def _error(message, status=400):
return _json({'status': 'error', 'message': message}, status)
def _enlace_publico(env, token):
base = url_publica(env, '')
ruta = '/formulario/%s' % token
return (base + ruta) if base else ruta
def _kind_de_campo(plantilla, clave):
for campo in plantilla.campos_lista():
if campo.get('clave') == clave:
return campo.get('momento') or 'otro'
return 'otro'
class SkeenFormulariosController(http.Controller):
# ------------------------------------------------------------------
# Plantillas
# ------------------------------------------------------------------
@http.route(PREFIX + '/plantillas', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('formularios.ver')
def listar_plantillas(self, **kw):
try:
incluir_inactivas = str(kw.get('include_inactive') or '') in ('1', 'true', 'True')
dominio = [] if incluir_inactivas else [('activo', '=', True)]
if kw.get('tipo'):
dominio.append(('tipo', '=', kw['tipo']))
plantillas = request.env['skeen.formulario.plantilla'].sudo().search(
dominio, order='secuencia, name, id')
return _json({
'status': 'success',
'plantillas': [plantilla_dict(p) for p in plantillas],
'total': len(plantillas),
})
except Exception as e:
return _error(str(e), 500)
@http.route(PREFIX + '/plantillas/<int:plantilla_id>', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('formularios.ver')
def get_plantilla(self, plantilla_id, **kw):
plantilla = request.env['skeen.formulario.plantilla'].sudo().browse(plantilla_id)
if not plantilla.exists():
return _error('Plantilla no encontrada', 404)
return _json({'status': 'success', 'plantilla': plantilla_dict(plantilla)})
@http.route(PREFIX + '/plantillas', type='http', auth='none',
methods=['POST'], csrf=False)
@require_role('recepcion')
@require_perm('formularios.crear')
def crear_plantilla(self, **kw):
data = _body()
try:
campos = normalizar_campos(data.get('campos') or [])
vals = self._vals_plantilla(data, campos, crear=True)
plantilla = request.env['skeen.formulario.plantilla'].sudo().create(vals)
self._set_m2m(plantilla, data)
return _json({'status': 'success', 'plantilla': plantilla_dict(plantilla)}, 201)
except ValueError as e:
return _error(str(e), 400)
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
@http.route(PREFIX + '/plantillas/<int:plantilla_id>', type='http', auth='none',
methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('formularios.editar')
def actualizar_plantilla(self, plantilla_id, **kw):
plantilla = request.env['skeen.formulario.plantilla'].sudo().browse(plantilla_id)
if not plantilla.exists():
return _error('Plantilla no encontrada', 404)
data = _body()
try:
campos = None
if 'campos' in data:
campos = normalizar_campos(data.get('campos') or [])
vals = self._vals_plantilla(data, campos, crear=False)
# Si la plantilla ya se usó, cualquier cambio de definición abre una
# versión nueva: las respuestas viejas conservan su snapshot.
definicion = {'campos', 'texto_legal', 'requiere_firma', 'requiere_foto',
'name', 'tipo', 'descripcion'}
if plantilla.tiene_respuestas() and (definicion & set(data.keys())):
vals['version'] = plantilla.version + 1
plantilla.write(vals)
self._set_m2m(plantilla, data)
return _json({'status': 'success', 'plantilla': plantilla_dict(plantilla)})
except ValueError as e:
return _error(str(e), 400)
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
@http.route(PREFIX + '/plantillas/<int:plantilla_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@require_role('recepcion')
@require_perm('formularios.editar')
def borrar_plantilla(self, plantilla_id, **kw):
plantilla = request.env['skeen.formulario.plantilla'].sudo().browse(plantilla_id)
if not plantilla.exists():
return _error('Plantilla no encontrada', 404)
try:
if plantilla.tiene_respuestas():
# Con respuestas asociadas solo se desactiva: se conserva el
# histórico del expediente y deja de ofrecerse para envíos.
plantilla.write({'activo': False})
return _json({'status': 'success', 'desactivada': True,
'message': 'La plantilla tiene respuestas: se desactivó en lugar de borrarse.',
'plantilla': plantilla_dict(plantilla)})
plantilla.unlink()
return _json({'status': 'success', 'desactivada': False,
'message': 'Plantilla eliminada'})
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
def _vals_plantilla(self, data, campos, crear):
vals = {}
if 'name' in data or crear:
name = str(data.get('name') or '').strip()
if not name:
raise ValueError('El nombre de la plantilla es obligatorio')
vals['name'] = name[:200]
if 'tipo' in data or crear:
tipo = str(data.get('tipo') or 'consentimiento').strip()
if tipo not in dict(request.env['skeen.formulario.plantilla']._fields['tipo'].selection):
raise ValueError('Tipo de plantilla no válido')
vals['tipo'] = tipo
if 'descripcion' in data:
vals['descripcion'] = str(data.get('descripcion') or '')[:2000]
if campos is not None:
vals['campos'] = json.dumps(campos, ensure_ascii=False)
if 'texto_legal' in data:
vals['texto_legal'] = str(data.get('texto_legal') or '')
for bandera in ('requiere_firma', 'requiere_foto', 'activo'):
if bandera in data:
vals[bandera] = bool(data[bandera])
if 'duracion_estimada_min' in data:
try:
vals['duracion_estimada_min'] = max(0, int(data['duracion_estimada_min']))
except (TypeError, ValueError):
raise ValueError('La duración estimada debe ser un número entero')
if 'secuencia' in data:
try:
vals['secuencia'] = int(data['secuencia'])
except (TypeError, ValueError):
raise ValueError('La secuencia debe ser un número entero')
return vals
def _set_m2m(self, plantilla, data):
Sucursal = request.env['skeen.sucursal'].sudo()
Servicio = request.env['skeen.servicio'].sudo()
if 'sucursal_ids' in data:
ids = [int(i) for i in (data.get('sucursal_ids') or []) if str(i).isdigit()]
plantilla.sucursales = [(6, 0, Sucursal.search([('id', 'in', ids)]).ids)]
if 'servicio_ids' in data:
ids = [int(i) for i in (data.get('servicio_ids') or []) if str(i).isdigit()]
plantilla.servicios = [(6, 0, Servicio.search([('id', 'in', ids)]).ids)]
# ------------------------------------------------------------------
# Respuestas
# ------------------------------------------------------------------
@http.route(PREFIX + '/respuestas', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('formularios.ver')
def listar_respuestas(self, **kw):
try:
Respuesta = request.env['skeen.formulario.respuesta'].sudo()
dominio = []
if kw.get('partner_id'):
dominio.append(('partner_id', '=', int(kw['partner_id'])))
if kw.get('plantilla_id'):
dominio.append(('plantilla_id', '=', int(kw['plantilla_id'])))
if kw.get('estado'):
dominio.append(('estado', '=', kw['estado']))
if kw.get('start'):
dominio.append(('create_date', '>=', '%s 00:00:00' % kw['start']))
if kw.get('end'):
dominio.append(('create_date', '<=', '%s 23:59:59' % kw['end']))
try:
page = max(1, int(kw.get('page') or 1))
except (TypeError, ValueError):
page = 1
try:
page_size = min(200, max(1, int(kw.get('page_size') or 50)))
except (TypeError, ValueError):
page_size = 50
total = Respuesta.search_count(dominio)
offset = (page - 1) * page_size
registros = Respuesta.search(dominio, order='create_date desc, id desc',
limit=page_size, offset=offset)
resumen = {}
for estado, _label in Respuesta._fields['estado'].selection:
resumen[estado] = Respuesta.search_count(dominio + [('estado', '=', estado)])
return _json({
'status': 'success',
'respuestas': [respuesta_dict(r) for r in registros],
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size if page_size else 0,
'resumen': resumen,
})
except Exception as e:
return _error(str(e), 500)
@http.route(PREFIX + '/respuestas/<int:respuesta_id>', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('formularios.ver')
def get_respuesta(self, respuesta_id, **kw):
respuesta = request.env['skeen.formulario.respuesta'].sudo().browse(respuesta_id)
if not respuesta.exists():
return _error('Respuesta no encontrada', 404)
return _json({'status': 'success', 'respuesta': respuesta_dict(respuesta, detalle=True)})
@http.route(PREFIX + '/fotos', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('formularios.ver')
def listar_fotos(self, **kw):
"""Fotos y archivos agrupables por fecha para la galería del expediente."""
try:
dominio = []
if kw.get('partner_id'):
dominio.append(('partner_id', '=', int(kw['partner_id'])))
if kw.get('respuesta_id'):
dominio.append(('respuesta_id', '=', int(kw['respuesta_id'])))
adjuntos = request.env['skeen.formulario.adjunto'].sudo().search(
dominio, order='create_date desc, id desc', limit=200)
return _json({
'status': 'success',
'total': len(adjuntos),
'fotos': [{
**adjunto_dict(a),
'respuesta_id': a.respuesta_id.id,
'respuesta_name': a.respuesta_id.name,
'plantilla': a.respuesta_id.plantilla_id.name,
'cita_id': a.cita_id.id if a.cita_id else None,
} for a in adjuntos],
})
except Exception as e:
return _error(str(e), 500)
@http.route(PREFIX + '/enviar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('formularios.enviar')
def enviar_formulario(self, **kw):
data = _body()
usuario = getattr(request, 'frontend_user', None)
try:
Plantilla = request.env['skeen.formulario.plantilla'].sudo()
plantilla = Plantilla.browse(int(data.get('plantilla_id') or 0))
if not plantilla.exists() or not plantilla.activo:
return _error('La plantilla no existe o está desactivada', 404)
partner = request.env['res.partner'].sudo().browse(int(data.get('partner_id') or 0))
if not partner.exists() or not partner.is_patient:
return _error('El paciente no existe', 404)
canal = str(data.get('canal') or 'enlace').strip().lower()
if canal not in ('whatsapp', 'email', 'enlace'):
return _error('Canal no válido (whatsapp, email o enlace)')
cita = None
if data.get('cita_id'):
cita = request.env['skeen.cita'].sudo().browse(int(data['cita_id']))
if not cita.exists() or cita.partner_id.id != partner.id:
cita = None
Respuesta = request.env['skeen.formulario.respuesta'].sudo()
respuesta = Respuesta.create({
'plantilla_id': plantilla.id,
'plantilla_version': plantilla.version,
'partner_id': partner.id,
'cita_id': cita.id if cita else False,
'estado': 'enviado',
'enviado_at': fields.Datetime.now(),
'canal_envio': canal,
'respuestas': '{}',
})
token = firmar_token(request.env, respuesta)
respuesta.write({'token': token})
enlace = _enlace_publico(request.env, token)
notificado = self._encolar_envio(respuesta, canal, enlace)
return _json({
'status': 'success',
'respuesta': respuesta_dict(respuesta),
'token': token,
'enlace': enlace,
'enviado_por': usuario.name if usuario else '',
'notificado': notificado,
}, 201)
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
def _encolar_envio(self, respuesta, canal, enlace):
"""Encola el aviso por el motor de notificaciones (tipo 'formulario').
Con `notificaciones.activo=False` (o modo prueba) no se envía nada real;
el canal 'enlace' no depende del motor y siempre devuelve el enlace.
"""
if canal not in ('whatsapp', 'email'):
return False
try:
service = request.env['skeen.notificacion.service'].sudo()
creadas = service.encolar(
partner=respuesta.partner_id,
cita=respuesta.cita_id or None,
tipo='formulario',
canal=canal,
clave_unica='formulario:r%s' % respuesta.id,
contexto_extra={'enlace': enlace, 'formulario': respuesta.plantilla_id.name},
)
cfg = get_notificaciones(request.env)
for notif in creadas:
service._intentar(notif, cfg)
return bool(creadas)
except Exception:
logging.getLogger(__name__).exception(
'No se pudo encolar el formulario %s', respuesta.id)
return False
@http.route(PREFIX + '/respuestas/<int:respuesta_id>/llenar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('formularios.crear')
def llenar_respuesta(self, respuesta_id, **kw):
respuesta = request.env['skeen.formulario.respuesta'].sudo().browse(respuesta_id)
if not respuesta.exists():
return _error('Respuesta no encontrada', 404)
if respuesta.estado == 'llenado':
return _error('Esta respuesta ya fue llenada', 409)
data = _body()
try:
plantilla = respuesta.plantilla_id
campos = plantilla.campos_lista()
fotos_claves = [a.campo_clave for a in respuesta.foto_ids if a.campo_clave]
firma_b64 = None
if data.get('firma_imagen'):
firma_b64, _mime = parse_data_url(
data['firma_imagen'], MAX_FIRMA_BYTES, ('image/png', 'image/jpeg'))
tiene_firma = bool(firma_b64) or bool(respuesta.firma_imagen)
errores = validar_respuestas(
plantilla, campos, data.get('respuestas'), fotos_claves,
tiene_firma, plantilla.requiere_firma, plantilla.requiere_foto)
if errores:
return _error(' '.join(errores), 400)
limpio = limpiar_respuestas(campos, data.get('respuestas'))
vals = {
'respuestas': json.dumps(limpio, ensure_ascii=False),
'estado': 'llenado',
'llenado_at': fields.Datetime.now(),
'creado_por_empleado': True,
}
if firma_b64:
vals['firma_imagen'] = firma_b64
if 'firma_nombre' in data:
vals['firma_nombre'] = str(data.get('firma_nombre') or '')[:200]
respuesta.write(vals)
return _json({'status': 'success', 'respuesta': respuesta_dict(respuesta, detalle=True)})
except ValueError as e:
return _error(str(e), 400)
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
# ------------------------------------------------------------------
# Adjuntos (fotos y archivos)
# ------------------------------------------------------------------
@http.route(PREFIX + '/adjuntar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('formularios.crear')
def adjuntar(self, **kw):
data = _body()
respuesta = request.env['skeen.formulario.respuesta'].sudo().browse(
int(data.get('respuesta_id') or 0))
if not respuesta.exists():
return _error('Respuesta no encontrada', 404)
try:
if len(respuesta.foto_ids) >= 10:
return _error('Ya hay 10 archivos adjuntos en esta respuesta')
b64, mimetype = parse_data_url(
data.get('file') or data.get('file_b64'), MAX_FOTO_BYTES, MIMES_FOTO)
campo_clave = str(data.get('campo_clave') or '')[:60]
kind = str(data.get('kind') or '').strip() or _kind_de_campo(respuesta.plantilla_id, campo_clave)
if kind not in ('antes', 'despues', 'otro'):
kind = 'otro'
adj = request.env['skeen.formulario.adjunto'].sudo().create({
'respuesta_id': respuesta.id,
'campo_clave': campo_clave,
'kind': kind,
'name': str(data.get('name') or 'adjunto')[:200],
'file': b64,
'mimetype': mimetype or str(data.get('mimetype') or ''),
'notes': str(data.get('notes') or '')[:200],
})
return _json({'status': 'success', 'adjunto': adjunto_dict(adj)}, 201)
except ValueError as e:
return _error(str(e), 400)
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
# ------------------------------------------------------------------
# Binarios (aceptan token de sesión por header o query string)
# ------------------------------------------------------------------
def _autorizado_binario(self, kw):
if current_user(request.env):
return True
payload = verify_session_token(request.env, kw.get('token') or '')
if not payload:
return False
user = request.env['skeen.frontend.user'].sudo().browse(payload.get('uid'))
return bool(user.exists() and user.active)
@http.route(PREFIX + '/respuestas/<int:respuesta_id>/firma', type='http', auth='none',
methods=['GET'], csrf=False)
def get_firma(self, respuesta_id, **kw):
if not self._autorizado_binario(kw):
return _error('No autenticado', 401)
respuesta = request.env['skeen.formulario.respuesta'].sudo().browse(respuesta_id)
if not respuesta.exists() or not respuesta.firma_imagen:
return _error('Firma no encontrada', 404)
return Response(
base64.b64decode(respuesta.firma_imagen), status=200, mimetype='image/png',
headers={'Content-Disposition': 'inline; filename="firma.png"',
'Access-Control-Allow-Origin': '*'},
)
@http.route(PREFIX + '/adjuntos/<int:adjunto_id>/file', type='http', auth='none',
methods=['GET'], csrf=False)
def get_adjunto_file(self, adjunto_id, **kw):
if not self._autorizado_binario(kw):
return _error('No autenticado', 401)
adj = request.env['skeen.formulario.adjunto'].sudo().browse(adjunto_id)
if not adj.exists() or not adj.file:
return _error('Adjunto no encontrado', 404)
return Response(
base64.b64decode(adj.file), status=200,
mimetype=adj.mimetype or 'application/octet-stream',
headers={
'Content-Disposition': 'inline; filename="%s"' % (adj.name or 'adjunto'),
'Access-Control-Allow-Origin': '*',
},
)

View File

@@ -0,0 +1,273 @@
# -*- coding: utf-8 -*-
"""Portal público de formularios (el paciente llena desde su celular).
Endpoints anónimos bajo `/skeen/frontend/v1/public/formulario`. Siguen el mismo
patrón que el portal de reservas: rate limiting por IP, honeypot en el llenado,
token HMAC como única credencial y 404 cuando el enlace no aplica. No se expone
ningún dato interno: solo el nombre de pila del paciente, el nombre de la
plantilla, sus campos públicos, el texto legal y el brand/glosario vigentes.
"""
import json
import logging
from odoo import fields, http
from odoo.http import request
from odoo.addons.skeen_whatsapp.controllers import public_booking as _pb
from odoo.addons.skeen_citas.clinic_settings import brand_dict, get_settings
from odoo.addons.skeen_citas.glosario import resolve as resolver_glosario
from .comun import (
respuesta_de_token, validar_respuestas, limpiar_respuestas,
parse_data_url, MAX_FOTO_BYTES, MAX_FIRMA_BYTES, MAX_FOTOS, MIMES_FOTO,
)
_logger = logging.getLogger(__name__)
PREFIX = '/skeen/frontend/v1/public/formulario'
# Buckets propios dentro del rate limiter compartido del portal público.
_pb.RATE_LIMITS.update({'form_config': (60, 60), 'form_fill': (10, 60)})
# Reexport del helper JSON/CORS del portal de reservas.
_json = _pb._json
_client_ip = _pb._client_ip
_rate_limited = _pb._rate_limited
def _404():
return _json({'status': 'error', 'message': 'Enlace no válido o expirado'}, 404)
def _brand(env):
"""Brand vigente con defaults, para el portal sin sesión."""
guardado = brand_dict(env)
settings = get_settings(env)
out = {
'nombre': settings.get('nombre') or 'Clínica',
'logo_claro': '/brand/logo-blanco.svg',
'logo_oscuro': '/brand/logo-negro.svg',
'colores': {'primario': '', 'acento': ''},
}
if isinstance(guardado, dict):
if guardado.get('nombre'):
out['nombre'] = str(guardado['nombre'])
for k in ('logo_claro', 'logo_oscuro'):
if guardado.get(k):
out[k] = str(guardado[k])
colores = guardado.get('colores')
if isinstance(colores, dict):
for c in ('primario', 'acento'):
if colores.get(c):
out['colores'][c] = str(colores[c])
return out
def _campos_publicos(campos):
out = []
for campo in campos:
item = {
'clave': campo.get('clave'),
'etiqueta': campo.get('etiqueta') or campo.get('clave'),
'tipo': campo.get('tipo'),
'obligatorio': bool(campo.get('obligatorio')),
'ayuda': campo.get('ayuda') or '',
'opciones': campo.get('opciones') or [],
}
if campo.get('tipo') in ('foto', 'archivo'):
item['momento'] = campo.get('momento') or 'otro'
out.append(item)
return out
def _primer_nombre(partner):
nombre = (partner.name or '').strip()
return nombre.split(' ')[0] if nombre else ''
class SkeenPublicFormularioController(http.Controller):
# ------------------------------------------------------------------
# 1. Configuración pública del formulario
# ------------------------------------------------------------------
@http.route(PREFIX + '/config', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
def form_config(self, **kw):
if request.httprequest.method == 'OPTIONS':
return _json({}, 200)
if _rate_limited('form_config'):
return _json({'status': 'error',
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
respuesta = respuesta_de_token(request.env, kw.get('token'))
if not respuesta:
return _404()
try:
plantilla = respuesta.plantilla_id
_pack, terms = resolver_glosario(request.env)
cita = respuesta.cita_id
return _json({
'status': 'success',
'brand': _brand(request.env),
'terms': terms,
'paciente': {'nombre': _primer_nombre(respuesta.partner_id)},
'cita': {
'fecha': cita.date.strftime('%Y-%m-%d') if cita and cita.date else None,
'hora': cita.time_str if cita else None,
'servicio': cita.servicio_id.name if cita and cita.servicio_id else None,
} if cita else None,
'estado': respuesta.estado,
'ya_llenado': respuesta.estado == 'llenado',
'plantilla': {
'nombre': plantilla.name,
'tipo': plantilla.tipo,
'tipo_label': dict(plantilla._fields['tipo'].selection).get(plantilla.tipo),
'descripcion': plantilla.descripcion or '',
'texto_legal': plantilla.texto_legal or '',
'requiere_firma': plantilla.requiere_firma,
'requiere_foto': plantilla.requiere_foto,
'duracion_estimada_min': plantilla.duracion_estimada_min,
'campos': _campos_publicos(plantilla.campos_lista()),
},
})
except Exception:
_logger.exception('Error al servir la config del formulario público')
return _json({'status': 'error',
'message': 'No se pudo cargar el formulario. Intenta de nuevo.'}, 500)
# ------------------------------------------------------------------
# 2. Estado de la respuesta (¿ya se llenó?)
# ------------------------------------------------------------------
@http.route(PREFIX, type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
def form_estado(self, **kw):
if request.httprequest.method == 'OPTIONS':
return _json({}, 200)
if _rate_limited('form_config'):
return _json({'status': 'error',
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
respuesta = respuesta_de_token(request.env, kw.get('token'))
if not respuesta:
return _404()
return _json({
'status': 'success',
'estado': respuesta.estado,
'ya_llenado': respuesta.estado == 'llenado',
'paciente': {'nombre': _primer_nombre(respuesta.partner_id)},
})
# ------------------------------------------------------------------
# 3. Llenado del formulario
# ------------------------------------------------------------------
@http.route(PREFIX + '/llenar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
def form_llenar(self, **kw):
if request.httprequest.method == 'OPTIONS':
return _json({}, 200)
if _rate_limited('form_fill'):
return _json({'status': 'error',
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
try:
data = json.loads(request.httprequest.get_data(as_text=True) or '{}')
except Exception:
data = {}
if str(data.get('hp') or '').strip():
return _json({'status': 'error', 'message': 'Solicitud rechazada'}, 400)
respuesta = respuesta_de_token(request.env, data.get('token'))
if not respuesta:
return _404()
if respuesta.estado == 'llenado':
return _json({'status': 'error',
'message': 'Este formulario ya fue llenado.'}, 409)
if respuesta.estado == 'cancelado':
return _json({'status': 'error',
'message': 'Este formulario ya no está disponible.'}, 409)
try:
plantilla = respuesta.plantilla_id
campos = plantilla.campos_lista()
firma_b64 = None
if data.get('firma_imagen'):
firma_b64, _mime = parse_data_url(
data['firma_imagen'], MAX_FIRMA_BYTES, ('image/png', 'image/jpeg'))
fotos = data.get('fotos') or []
if not isinstance(fotos, list):
return _json({'status': 'error', 'message': 'Las fotos deben ser una lista'}, 400)
if len(fotos) > MAX_FOTOS:
return _json({'status': 'error',
'message': 'Máximo %d fotos por formulario' % MAX_FOTOS}, 400)
fotos_listas = []
for item in fotos:
if isinstance(item, str):
b64, mime = parse_data_url(item, MAX_FOTO_BYTES, MIMES_FOTO)
fotos_listas.append({'file': b64, 'mimetype': mime,
'campo_clave': '', 'kind': 'otro',
'name': 'foto.jpg'})
continue
if not isinstance(item, dict):
return _json({'status': 'error', 'message': 'Foto no válida'}, 400)
b64, mime = parse_data_url(
item.get('file') or item.get('data_url'), MAX_FOTO_BYTES, MIMES_FOTO)
campo_clave = str(item.get('campo_clave') or '')[:60]
fotos_listas.append({
'file': b64,
'mimetype': mime,
'campo_clave': campo_clave,
'kind': _kind_de_campo(plantilla, campo_clave, item.get('kind')),
'name': str(item.get('name') or 'foto.jpg')[:200],
})
fotos_claves = [f['campo_clave'] for f in fotos_listas if f['campo_clave']]
errores = validar_respuestas(
plantilla, campos, data.get('respuestas'), fotos_claves,
bool(firma_b64), plantilla.requiere_firma, plantilla.requiere_foto)
if errores:
return _json({'status': 'error', 'message': ' '.join(errores),
'errores': errores}, 400)
limpio = limpiar_respuestas(campos, data.get('respuestas'))
vals = {
'respuestas': json.dumps(limpio, ensure_ascii=False),
'estado': 'llenado',
'llenado_at': fields.Datetime.now(),
'ip_llenado': _client_ip(),
'creado_por_empleado': False,
}
if firma_b64:
vals['firma_imagen'] = firma_b64
if data.get('firma_nombre'):
vals['firma_nombre'] = str(data['firma_nombre'])[:200]
respuesta.write(vals)
Adjunto = request.env['skeen.formulario.adjunto'].sudo()
for foto in fotos_listas:
Adjunto.create({
'respuesta_id': respuesta.id,
'campo_clave': foto['campo_clave'],
'kind': foto['kind'],
'name': foto['name'],
'file': foto['file'],
'mimetype': foto['mimetype'],
})
return _json({'status': 'success',
'message': '¡Gracias! Tu formulario quedó registrado.'})
except ValueError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 400)
except Exception:
request.env.cr.rollback()
_logger.exception('Error al guardar el llenado del formulario público')
return _json({'status': 'error',
'message': 'No se pudo guardar el formulario. Intenta de nuevo.'}, 500)
def _kind_de_campo(plantilla, clave, sugerido=None):
if sugerido in ('antes', 'despues', 'otro'):
return sugerido
for campo in plantilla.campos_lista():
if campo.get('clave') == clave and campo.get('momento') in ('antes', 'despues', 'otro'):
return campo['momento']
return 'otro'

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Folio de las respuestas de formulario (noupdate: no reiniciar en upgrades) -->
<data noupdate="1">
<record id="seq_skeen_formulario_respuesta" model="ir.sequence">
<field name="name">Respuesta de Formulario SKEEN</field>
<field name="code">skeen.formulario.respuesta</field>
<field name="prefix">FORM-</field>
<field name="padding">5</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import formulario_plantilla
from . import formulario_respuesta
from . import formulario_adjunto

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
"""Foto o archivo asociado a una respuesta de formulario.
Se guarda el binario con `attachment=True` (igual que `skeen.visita.adjunto`)
y se replica el paciente y la fecha de la respuesta para poder armar la
galería antes/después del expediente sin recorrer las respuestas.
"""
from odoo import fields, models
class SkeenFormularioAdjunto(models.Model):
_name = 'skeen.formulario.adjunto'
_description = 'Adjunto de respuesta de formulario'
_order = 'create_date asc, id asc'
respuesta_id = fields.Many2one('skeen.formulario.respuesta', string='Respuesta',
required=True, ondelete='cascade', index=True)
partner_id = fields.Many2one('res.partner', string='Paciente',
related='respuesta_id.partner_id', store=True, index=True)
cita_id = fields.Many2one('skeen.cita', string='Cita',
related='respuesta_id.cita_id', store=True)
campo_clave = fields.Char(string='Campo del formulario')
kind = fields.Selection([
('antes', 'Antes'),
('despues', 'Después'),
('otro', 'Otro'),
], string='Momento', default='otro', required=True)
name = fields.Char(string='Archivo')
file = fields.Binary(string='Contenido', attachment=True, required=True)
mimetype = fields.Char(string='Tipo MIME')
notes = fields.Char(string='Notas')

View File

@@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
"""Plantillas de formularios digitales (consentimientos, encuestas, etc.).
`campos` guarda un JSON con la lista ORDENADA de campos. Se usa Text en lugar
de Json para poder validar/limpiar el contenido en el controlador y mantenerlo
legible en la BD y en respaldos. Formato de cada campo:
{clave, etiqueta, tipo, obligatorio, ayuda, opciones[]}
`momento` es opcional y solo aplica a los campos de tipo `foto`/`archivo`
(antes | despues | otro), para armar la galería antes/después del expediente.
"""
import json
from odoo import api, fields, models
TIPOS_CAMPO = (
'texto', 'texto_largo', 'numero', 'fecha', 'seleccion',
'multi_seleccion', 'checkbox', 'escala', 'firma', 'foto',
'archivo', 'consentimiento',
)
TIPOS_PLANTILLA = (
('consentimiento', 'Consentimiento informado'),
('expediente', 'Nota de expediente'),
('encuesta', 'Encuesta'),
('autorizacion', 'Autorización'),
)
MOMENTOS = ('antes', 'despues', 'otro')
MAX_CAMPOS = 60
MAX_OPCIONES = 40
MAX_TEXTO = 300
class SkeenFormularioPlantilla(models.Model):
_name = 'skeen.formulario.plantilla'
_description = 'Plantilla de formulario digital'
_order = 'secuencia, name, id'
name = fields.Char(string='Nombre', required=True)
tipo = fields.Selection(TIPOS_PLANTILLA, string='Tipo', required=True,
default='consentimiento', index=True)
activo = fields.Boolean(string='Activo', default=True, index=True)
version = fields.Integer(string='Versión', default=1, readonly=True, copy=False)
descripcion = fields.Text(string='Descripción')
campos = fields.Text(string='Campos (JSON)', default='[]')
texto_legal = fields.Text(string='Texto legal / consentimiento')
requiere_firma = fields.Boolean(string='Requiere firma', default=False)
requiere_foto = fields.Boolean(string='Requiere foto', default=False)
duracion_estimada_min = fields.Integer(string='Duración estimada (min)', default=5)
secuencia = fields.Integer(string='Secuencia', default=10)
sucursales = fields.Many2many(
'skeen.sucursal', 'skeen_formulario_plantilla_sucursal_rel',
'plantilla_id', 'sucursal_id', string='Sucursales',
help='Vacío = todas las sucursales.')
servicios = fields.Many2many(
'skeen.servicio', 'skeen_formulario_plantilla_servicio_rel',
'plantilla_id', 'servicio_id', string='Servicios',
help='Sugerir esta plantilla cuando el paciente tenga uno de estos servicios.')
respuesta_ids = fields.One2many('skeen.formulario.respuesta', 'plantilla_id',
string='Respuestas')
respuestas_count = fields.Integer(string='# Respuestas', compute='_compute_respuestas_count')
@api.depends('respuesta_ids')
def _compute_respuestas_count(self):
for rec in self:
rec.respuestas_count = len(rec.respuesta_ids)
def campos_lista(self):
"""Lista de campos normalizada (nunca lanza: JSON inválido -> [])."""
self.ensure_one()
try:
data = json.loads(self.campos or '[]')
except (ValueError, TypeError):
return []
return data if isinstance(data, list) else []
def tiene_respuestas(self):
self.ensure_one()
return bool(self.respuesta_ids)
def normalizar_campos(raw):
"""Valida y limpia la lista de campos de un payload. Lanza ValueError.
Devuelve siempre una lista nueva (JSON-serializable). Se descartan claves
repetidas, tipos desconocidos y opciones fuera de rango.
"""
if raw is None:
raise ValueError('Falta la lista de campos')
if not isinstance(raw, list):
raise ValueError('Los campos deben ser una lista')
if len(raw) > MAX_CAMPOS:
raise ValueError('Demasiados campos (máximo %d)' % MAX_CAMPOS)
out = []
claves = set()
for i, campo in enumerate(raw):
if not isinstance(campo, dict):
raise ValueError('Cada campo debe ser un objeto')
clave = str(campo.get('clave') or '').strip()
if not clave:
raise ValueError('El campo #%d no tiene clave' % (i + 1))
if len(clave) > 60:
raise ValueError('La clave del campo #%d es demasiado larga' % (i + 1))
if clave in claves:
raise ValueError('La clave "%s" está repetida' % clave)
claves.add(clave)
tipo = str(campo.get('tipo') or '').strip()
if tipo not in TIPOS_CAMPO:
raise ValueError('Tipo de campo no válido: %s' % (tipo or '(vacío)'))
etiqueta = str(campo.get('etiqueta') or '').strip() or clave
opciones = []
if tipo in ('seleccion', 'multi_seleccion'):
raw_op = campo.get('opciones') or []
if not isinstance(raw_op, list) or not raw_op:
raise ValueError('El campo "%s" necesita al menos una opción' % etiqueta)
if len(raw_op) > MAX_OPCIONES:
raise ValueError('El campo "%s" tiene demasiadas opciones' % etiqueta)
for o in raw_op:
texto = str(o or '').strip()[:MAX_TEXTO]
if texto and texto not in opciones:
opciones.append(texto)
if not opciones:
raise ValueError('El campo "%s" necesita al menos una opción' % etiqueta)
clean = {
'clave': clave,
'etiqueta': etiqueta[:MAX_TEXTO],
'tipo': tipo,
'obligatorio': bool(campo.get('obligatorio')),
'ayuda': str(campo.get('ayuda') or '').strip()[:MAX_TEXTO],
'opciones': opciones,
}
if tipo in ('foto', 'archivo'):
momento = str(campo.get('momento') or 'otro').strip()
clean['momento'] = momento if momento in MOMENTOS else 'otro'
out.append(clean)
return out

View File

@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
"""Respuesta (instancia llenada) de una plantilla de formulario.
Guarda el snapshot de versión de la plantilla (`plantilla_version`), las
respuestas como JSON en `respuestas`, la firma como PNG en `firma_imagen`
(Binary) y las fotos/archivos en `skeen.formulario.adjunto`.
El `token` es un HMAC que viaja en el enlace público; una vez llenada la
respuesta el token deja de ser válido para reenvíos.
"""
from odoo import api, fields, models
class SkeenFormularioRespuesta(models.Model):
_name = 'skeen.formulario.respuesta'
_description = 'Respuesta de formulario'
_order = 'create_date desc, id desc'
name = fields.Char(string='Folio', readonly=True, copy=False)
plantilla_id = fields.Many2one('skeen.formulario.plantilla', string='Plantilla',
required=True, ondelete='restrict', index=True)
plantilla_version = fields.Integer(string='Versión de la plantilla', default=1)
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
ondelete='cascade', index=True,
domain=[('is_patient', '=', True)])
cita_id = fields.Many2one('skeen.cita', string='Cita', ondelete='set null', index=True)
respuestas = fields.Text(string='Respuestas (JSON)', default='{}')
firma_imagen = fields.Binary(string='Firma (PNG)', attachment=True)
firma_nombre = fields.Char(string='Firmado por')
foto_ids = fields.One2many('skeen.formulario.adjunto', 'respuesta_id', string='Fotos y archivos')
estado = fields.Selection([
('borrador', 'Borrador'),
('enviado', 'Enviado'),
('llenado', 'Llenado'),
('cancelado', 'Cancelado'),
], string='Estado', default='borrador', required=True, index=True)
enviado_at = fields.Datetime(string='Enviado el')
llenado_at = fields.Datetime(string='Llenado el')
token = fields.Char(string='Token', index=True, copy=False)
canal_envio = fields.Selection([
('whatsapp', 'WhatsApp'),
('email', 'Correo'),
('enlace', 'Enlace'),
('manual', 'Manual'),
], string='Canal de envío', default='enlace')
ip_llenado = fields.Char(string='IP de llenado')
creado_por_empleado = fields.Boolean(string='Llenado por el personal', default=False)
notas = fields.Text(string='Notas')
_sql_constraints = [
('token_uniq', 'unique(token)',
'El token del formulario debe ser único.'),
]
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name'):
vals['name'] = (self.env['ir.sequence'].next_by_code('skeen.formulario.respuesta')
or 'FORM-00001')
return super().create(vals_list)
def tiene_firma(self):
self.ensure_one()
return bool(self.firma_imagen)

View File

@@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_skeen_formulario_plantilla,Acceso a Plantillas de Formulario SKEEN,model_skeen_formulario_plantilla,base.group_user,1,1,1,1
access_skeen_formulario_respuesta,Acceso a Respuestas de Formulario SKEEN,model_skeen_formulario_respuesta,base.group_user,1,1,1,1
access_skeen_formulario_adjunto,Acceso a Adjuntos de Formulario SKEEN,model_skeen_formulario_adjunto,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_skeen_formulario_plantilla Acceso a Plantillas de Formulario SKEEN model_skeen_formulario_plantilla base.group_user 1 1 1 1
3 access_skeen_formulario_respuesta Acceso a Respuestas de Formulario SKEEN model_skeen_formulario_respuesta base.group_user 1 1 1 1
4 access_skeen_formulario_adjunto Acceso a Adjuntos de Formulario SKEEN model_skeen_formulario_adjunto base.group_user 1 1 1 1

View File

@@ -43,6 +43,7 @@ TIPOS = [
('cumpleanos', 'Cumpleaños'),
('post_visita', 'Post-visita'),
('recuperacion', 'Recuperación'),
('formulario', 'Formulario'),
('manual', 'Manual'),
]
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')]
@@ -64,6 +65,8 @@ PLANTILLA_POR_TIPO = {
# bloque `notificaciones` solo define cuatro plantillas (recordatorio,
# confirmación, cumpleaños y post-visita).
'recuperacion': 'plantilla_recordatorio',
# El aviso de un formulario digital usa su propia plantilla con {enlace}.
'formulario': 'plantilla_formulario',
'manual': 'plantilla_recordatorio',
}
@@ -129,12 +132,13 @@ class SkeenNotificacionService(models.AbstractModel):
return clinic_get_notificaciones(self.env)
@api.model
def render(self, plantilla, cita=None, partner=None, cfg=None):
def render(self, plantilla, cita=None, partner=None, cfg=None, extra=None):
"""Sustituye las variables de una plantilla con datos reales.
Los nombres de los términos (`{cita}`) salen del glosario vigente; las
demás variables son los datos de la cita y del paciente. Una variable sin
dato queda como cadena vacía.
dato queda como cadena vacía. `extra` añade variables puntuales (p. ej.
`{enlace}` y `{formulario}` para el aviso de un formulario digital).
"""
cfg = cfg or self._cfg()
settings = clinic_get_settings(self.env)
@@ -166,6 +170,10 @@ class SkeenNotificacionService(models.AbstractModel):
contexto['fecha'] = fields.Date.today().strftime('%d/%m/%Y')
contexto['sucursal'] = self._sucursal_default()
if extra:
for clave, valor in extra.items():
contexto[clave] = '' if valor is None else str(valor)
texto = str(plantilla or '')
for clave, valor in contexto.items():
texto = texto.replace('{%s}' % clave, str(valor or ''))
@@ -221,12 +229,14 @@ class SkeenNotificacionService(models.AbstractModel):
# ------------------------------------------------------------------
@api.model
def encolar(self, cita=None, tipo='recordatorio', cuando=None, canal=None,
partner=None, ventana=None, clave_unica=None, asunto=None):
partner=None, ventana=None, clave_unica=None, asunto=None,
contexto_extra=None):
"""Crea las notificaciones pendientes de una cita (una por canal).
Con `activo=False` no hace nada. No duplica: si ya existe la misma
`clave_unica` (misma cita + tipo + ventana + canal) se salta. Devuelve el
recordset de lo creado.
recordset de lo creado. `contexto_extra` alimenta variables adicionales
del render (p. ej. `{enlace}` del formulario digital).
"""
Notif = self.env['skeen.notificacion'].sudo()
cfg = self._cfg()
@@ -239,7 +249,8 @@ class SkeenNotificacionService(models.AbstractModel):
plantilla = cfg.get(PLANTILLA_POR_TIPO.get(tipo, 'plantilla_recordatorio')) or ''
if not plantilla.strip():
return Notif
mensaje = self.render(plantilla, cita=cita, partner=partner, cfg=cfg)
mensaje = self.render(plantilla, cita=cita, partner=partner, cfg=cfg,
extra=contexto_extra)
if asunto is None:
asunto = self._asunto(tipo)

View File

@@ -89,6 +89,12 @@ PERMISSION_GROUPS = [
('notificaciones.enviar', 'Enviar y reintentar notificaciones'),
('notificaciones.editar', 'Configurar recordatorios y plantillas'),
]),
('formularios', 'Formularios y consentimientos', [
('formularios.ver', 'Ver formularios y respuestas'),
('formularios.crear', 'Llenar formularios (recepción)'),
('formularios.editar', 'Administrar plantillas de formularios'),
('formularios.enviar', 'Enviar formularios a pacientes'),
]),
('config', 'Usuarios y configuración', [
('config.usuarios', 'Administrar usuarios'),
('config.roles', 'Administrar roles y menús'),
@@ -116,6 +122,7 @@ READ_PERM_KEYS = (
'ventas.reimprimir',
'whatsapp.ver',
'notificaciones.ver',
'formularios.ver',
)
# ---------------------------------------------------------------------------
@@ -197,6 +204,12 @@ DEFAULT_PERM_ROLES = {
'notificaciones.ver': ALL,
'notificaciones.enviar': W,
'notificaciones.editar': AG,
# Formularios digitales y consentimientos: ver es de todos; llenar/enviar lo
# hace quien atiende al paciente; administrar plantillas es de admin/gerencia.
'formularios.ver': ALL,
'formularios.crear': ('admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'),
'formularios.editar': AG,
'formularios.enviar': ('admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'),
# Usuarios y configuración
'config.usuarios': AG,
'config.roles': AG,