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:
@@ -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>} />
|
||||
|
||||
264
frontend/src/components/FormulariosPaciente.tsx
Normal file
264
frontend/src/components/FormulariosPaciente.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Camera, ClipboardList, Clock, FileSignature, Send } from 'lucide-react';
|
||||
import { Badge, Button, Modal, Skeleton, EmptyState, toast } from './ui';
|
||||
import { odooApi, type FormularioFoto, type FormularioRespuesta } from '../services/odoo';
|
||||
import EnviarFormularioModal from './formulario/EnviarFormularioModal';
|
||||
import { MOMENTO_LABEL, valorLegible } from './formulario/tipos';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
|
||||
// Sección "Formularios" del expediente del paciente: respuestas (con quién y
|
||||
// cuándo), pendientes marcados, detalle con firma y fotos, y la galería de
|
||||
// fotos antes/después agrupada por fecha.
|
||||
|
||||
interface Props {
|
||||
patientId: number;
|
||||
patientName?: string;
|
||||
}
|
||||
|
||||
const estadoVariant = (estado: string) =>
|
||||
estado === 'llenado' ? 'success' : estado === 'enviado' ? 'warning' : estado === 'cancelado' ? 'danger' : 'default';
|
||||
|
||||
const fechaCorta = (s: string | null) => {
|
||||
if (!s) return '—';
|
||||
const d = new Date(`${s.slice(0, 10)}T12:00:00`);
|
||||
if (Number.isNaN(d.getTime())) return s.slice(0, 10);
|
||||
return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
};
|
||||
|
||||
const FormulariosPaciente: FC<Props> = ({ patientId, patientName = '' }) => {
|
||||
const { can } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const [respuestas, setRespuestas] = useState<FormularioRespuesta[]>([]);
|
||||
const [fotos, setFotos] = useState<FormularioFoto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detalle, setDetalle] = useState<FormularioRespuesta | null>(null);
|
||||
const [enviarOpen, setEnviarOpen] = useState(false);
|
||||
|
||||
const cargar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [resR, resF] = await Promise.all([
|
||||
odooApi.getFormularioRespuestas({ partner_id: patientId }),
|
||||
odooApi.getFormularioFotos({ partner_id: patientId }),
|
||||
]);
|
||||
if (resR.status === 'success') setRespuestas(resR.respuestas);
|
||||
if (resF.status === 'success') setFotos(resF.fotos);
|
||||
} catch {
|
||||
toast.error('No se pudieron cargar los formularios');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [patientId]);
|
||||
|
||||
useEffect(() => { cargar(); }, [cargar]);
|
||||
|
||||
const pendientes = respuestas.filter((r) => r.estado === 'enviado');
|
||||
|
||||
const galeria = useMemo(() => {
|
||||
const grupos = new Map<string, FormularioFoto[]>();
|
||||
for (const f of fotos) {
|
||||
const dia = (f.create_date || '').slice(0, 10) || 'sin-fecha';
|
||||
if (!grupos.has(dia)) grupos.set(dia, []);
|
||||
grupos.get(dia)!.push(f);
|
||||
}
|
||||
return Array.from(grupos.entries()).sort((a, b) => (a[0] < b[0] ? 1 : -1));
|
||||
}, [fotos]);
|
||||
|
||||
const abrirDetalle = async (id: number) => {
|
||||
try {
|
||||
const res = await odooApi.getFormularioRespuesta(id);
|
||||
if (res.status === 'success') setDetalle(res.respuesta);
|
||||
} catch {
|
||||
toast.error('No se pudo abrir el formulario');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h4 className="flex items-center text-sm font-semibold text-theme-heading">
|
||||
<FileSignature size={16} className="mr-2 text-theme-muted" /> Formularios y consentimientos
|
||||
</h4>
|
||||
{can('formularios.enviar') && (
|
||||
<Button size="sm" variant="outline" onClick={() => setEnviarOpen(true)}>
|
||||
<Send size={14} className="mr-1.5" /> Enviar formulario
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pendientes.length > 0 && (
|
||||
<div className="rounded-xl border border-amber-300 bg-amber-50 p-3">
|
||||
<p className="flex items-center text-xs font-medium text-amber-800">
|
||||
<Clock size={13} className="mr-1.5" /> {pendientes.length} formulario{pendientes.length > 1 ? 's' : ''} pendiente{pendientes.length > 1 ? 's' : ''} de llenar
|
||||
</p>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{pendientes.map((r) => (
|
||||
<li key={r.id} className="text-xs text-amber-800">
|
||||
{r.plantilla} · enviado {fechaCorta(r.enviado_at || r.create_date)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={3} className="h-12 w-full" />
|
||||
) : respuestas.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin formularios"
|
||||
subtitle={`Este ${term('patient_singular').toLowerCase()} aún no tiene formularios ni consentimientos.`}
|
||||
icon={<ClipboardList size={26} />}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{respuestas.map((r) => (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => abrirDetalle(r.id)}
|
||||
className={`w-full rounded-xl border p-3 text-left transition hover:shadow-card ${
|
||||
r.estado === 'enviado' ? 'border-amber-300 bg-amber-50/50' : 'border-theme-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-theme-heading">{r.plantilla}</p>
|
||||
<Badge variant={estadoVariant(r.estado)}>{r.estado_label || r.estado}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-theme-muted">
|
||||
{r.name} · {fechaCorta(r.llenado_at || r.enviado_at || r.create_date)}
|
||||
{' · '}
|
||||
{r.creado_por_empleado ? 'Llenado por el personal' : `Llenado por el ${term('patient_singular').toLowerCase()}`}
|
||||
{r.tiene_firma ? ' · firmado' : ''}
|
||||
{r.fotos_count > 0 ? ` · ${r.fotos_count} foto${r.fotos_count > 1 ? 's' : ''}` : ''}
|
||||
</p>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{galeria.length > 0 && (
|
||||
<section>
|
||||
<h4 className="mb-2 flex items-center text-sm font-semibold text-theme-heading">
|
||||
<Camera size={16} className="mr-2 text-theme-muted" /> Galería antes / después
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
{galeria.map(([dia, items]) => (
|
||||
<div key={dia}>
|
||||
<p className="mb-1.5 text-xs font-medium text-theme-muted">{fechaCorta(dia)}</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{items.map((f) => (
|
||||
<figure key={f.id} className="relative">
|
||||
<img
|
||||
src={odooApi.formularioArchivoUrl(f.url)}
|
||||
alt={f.name}
|
||||
className="h-28 w-full rounded-xl border border-theme-border object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption className="absolute bottom-1 left-1 rounded-md bg-theme-surface/90 px-1.5 py-0.5 text-[10px] font-medium text-theme-heading">
|
||||
{MOMENTO_LABEL[f.kind] || f.kind}
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
isOpen={!!detalle}
|
||||
onClose={() => setDetalle(null)}
|
||||
title={detalle ? `${detalle.plantilla} — ${detalle.name}` : 'Formulario'}
|
||||
maxWidth="2xl"
|
||||
>
|
||||
{detalle && (
|
||||
<div className="max-h-[70vh] space-y-4 overflow-y-auto pr-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-theme-muted">
|
||||
<Badge variant={estadoVariant(detalle.estado)}>{detalle.estado_label || detalle.estado}</Badge>
|
||||
<span>{fechaCorta(detalle.llenado_at || detalle.enviado_at || detalle.create_date)}</span>
|
||||
<span>· {detalle.creado_por_empleado ? 'Llenado por el personal' : `Llenado por el ${term('patient_singular').toLowerCase()}`}</span>
|
||||
</div>
|
||||
|
||||
{(detalle.campos || []).length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{(detalle.campos || [])
|
||||
.filter((c) => !['firma', 'foto', 'archivo'].includes(c.tipo))
|
||||
.map((c) => (
|
||||
<div key={c.clave} className="rounded-xl bg-theme-bg p-3">
|
||||
<p className="text-xs text-theme-muted">{c.etiqueta}</p>
|
||||
<p className="text-sm text-theme-heading whitespace-pre-wrap">
|
||||
{valorLegible(c, detalle.respuestas?.[c.clave])}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detalle.texto_legal && (
|
||||
<details className="rounded-xl bg-theme-bg p-3">
|
||||
<summary className="cursor-pointer text-xs font-medium text-theme-muted">Texto legal aceptado</summary>
|
||||
<p className="mt-2 text-xs leading-relaxed text-theme-muted whitespace-pre-wrap">{detalle.texto_legal}</p>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{detalle.tiene_firma && detalle.firma_url && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs text-theme-muted">Firma{detalle.firma_nombre ? ` de ${detalle.firma_nombre}` : ''}</p>
|
||||
<img
|
||||
src={odooApi.formularioArchivoUrl(detalle.firma_url)}
|
||||
alt="Firma"
|
||||
className="max-h-40 rounded-xl border border-theme-border bg-white p-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(detalle.fotos || []).length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs text-theme-muted">Fotos y archivos</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{(detalle.fotos || []).map((f) => (
|
||||
<figure key={f.id} className="relative">
|
||||
{f.mimetype.startsWith('image/') ? (
|
||||
<img
|
||||
src={odooApi.formularioArchivoUrl(f.url)}
|
||||
alt={f.name}
|
||||
className="h-32 w-full rounded-xl border border-theme-border object-cover"
|
||||
/>
|
||||
) : (
|
||||
<a
|
||||
href={odooApi.formularioArchivoUrl(f.url)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex h-32 items-center justify-center rounded-xl border border-theme-border bg-theme-bg px-2 text-center text-xs text-theme-heading"
|
||||
>
|
||||
{f.name}
|
||||
</a>
|
||||
)}
|
||||
<figcaption className="absolute bottom-1 left-1 rounded-md bg-theme-surface/90 px-1.5 py-0.5 text-[10px] font-medium text-theme-heading">
|
||||
{MOMENTO_LABEL[f.kind] || f.kind}
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<EnviarFormularioModal
|
||||
isOpen={enviarOpen}
|
||||
onClose={() => setEnviarOpen(false)}
|
||||
patientId={patientId}
|
||||
patientName={patientName}
|
||||
onSent={cargar}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormulariosPaciente;
|
||||
438
frontend/src/components/FormulariosPanel.tsx
Normal file
438
frontend/src/components/FormulariosPanel.tsx
Normal 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;
|
||||
@@ -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) =>
|
||||
|
||||
354
frontend/src/components/formulario/CampoFormulario.tsx
Normal file
354
frontend/src/components/formulario/CampoFormulario.tsx
Normal 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;
|
||||
170
frontend/src/components/formulario/EnviarFormularioModal.tsx
Normal file
170
frontend/src/components/formulario/EnviarFormularioModal.tsx
Normal 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;
|
||||
124
frontend/src/components/formulario/FirmaCanvas.tsx
Normal file
124
frontend/src/components/formulario/FirmaCanvas.tsx
Normal 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;
|
||||
140
frontend/src/components/formulario/FormularioCampos.tsx
Normal file
140
frontend/src/components/formulario/FormularioCampos.tsx
Normal 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;
|
||||
122
frontend/src/components/formulario/tipos.ts
Normal file
122
frontend/src/components/formulario/tipos.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
241
frontend/src/pages/FormularioPublico.tsx
Normal file
241
frontend/src/pages/FormularioPublico.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
4
odoo-addons/skeen_formularios/__init__.py
Normal file
4
odoo-addons/skeen_formularios/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
26
odoo-addons/skeen_formularios/__manifest__.py
Normal file
26
odoo-addons/skeen_formularios/__manifest__.py
Normal 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',
|
||||
}
|
||||
5
odoo-addons/skeen_formularios/controllers/__init__.py
Normal file
5
odoo-addons/skeen_formularios/controllers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import comun
|
||||
from . import formularios
|
||||
from . import public_formulario
|
||||
323
odoo-addons/skeen_formularios/controllers/comun.py
Normal file
323
odoo-addons/skeen_formularios/controllers/comun.py
Normal 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
|
||||
496
odoo-addons/skeen_formularios/controllers/formularios.py
Normal file
496
odoo-addons/skeen_formularios/controllers/formularios.py
Normal 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': '*',
|
||||
},
|
||||
)
|
||||
273
odoo-addons/skeen_formularios/controllers/public_formulario.py
Normal file
273
odoo-addons/skeen_formularios/controllers/public_formulario.py
Normal 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'
|
||||
14
odoo-addons/skeen_formularios/data/sequences.xml
Normal file
14
odoo-addons/skeen_formularios/data/sequences.xml
Normal 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>
|
||||
5
odoo-addons/skeen_formularios/models/__init__.py
Normal file
5
odoo-addons/skeen_formularios/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import formulario_plantilla
|
||||
from . import formulario_respuesta
|
||||
from . import formulario_adjunto
|
||||
32
odoo-addons/skeen_formularios/models/formulario_adjunto.py
Normal file
32
odoo-addons/skeen_formularios/models/formulario_adjunto.py
Normal 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')
|
||||
139
odoo-addons/skeen_formularios/models/formulario_plantilla.py
Normal file
139
odoo-addons/skeen_formularios/models/formulario_plantilla.py
Normal 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
|
||||
68
odoo-addons/skeen_formularios/models/formulario_respuesta.py
Normal file
68
odoo-addons/skeen_formularios/models/formulario_respuesta.py
Normal 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)
|
||||
@@ -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
|
||||
|
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user