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 Login from './pages/Login';
|
||||||
import Reservar from './pages/Reservar';
|
import Reservar from './pages/Reservar';
|
||||||
import ReservarCita from './pages/ReservarCita';
|
import ReservarCita from './pages/ReservarCita';
|
||||||
|
import FormularioPublico from './pages/FormularioPublico';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
const FullScreenLoader: FC = () => (
|
const FullScreenLoader: FC = () => (
|
||||||
@@ -140,6 +141,8 @@ const App: FC = () => {
|
|||||||
{/* Portal público de reservas: FUERA del gate de autenticación. */}
|
{/* Portal público de reservas: FUERA del gate de autenticación. */}
|
||||||
<Route path="/reservar" element={<Reservar />} />
|
<Route path="/reservar" element={<Reservar />} />
|
||||||
<Route path="/reservar/cita/:token" element={<ReservarCita />} />
|
<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="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} />
|
||||||
<Route path="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
|
<Route path="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
|
||||||
<Route path="/mis-ventas" element={<RequireAuth href="/mis-ventas"><MisVentas /></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_confirmacion', label: 'Confirmación' },
|
||||||
{ key: 'plantilla_cumpleanos', label: 'Cumpleaños' },
|
{ key: 'plantilla_cumpleanos', label: 'Cumpleaños' },
|
||||||
{ key: 'plantilla_post_visita', label: 'Post-visita' },
|
{ key: 'plantilla_post_visita', label: 'Post-visita' },
|
||||||
|
{ key: 'plantilla_formulario', label: 'Formulario digital' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const NotificacionesPanel: FC<Props> = ({ settings, onSaved }) => {
|
const NotificacionesPanel: FC<Props> = ({ settings, onSaved }) => {
|
||||||
@@ -51,6 +52,8 @@ const NotificacionesPanel: FC<Props> = ({ settings, onSaved }) => {
|
|||||||
doctor: 'Dra. García',
|
doctor: 'Dra. García',
|
||||||
sucursal: settings.nombre || 'Sucursal Centro',
|
sucursal: settings.nombre || 'Sucursal Centro',
|
||||||
telefono: settings.telefono || '+52 661 000 0000',
|
telefono: settings.telefono || '+52 661 000 0000',
|
||||||
|
enlace: 'https://clinica.mx/formulario/abc123',
|
||||||
|
formulario: 'Consentimiento informado',
|
||||||
}), [term, settings.nombre, settings.telefono]);
|
}), [term, settings.nombre, settings.telefono]);
|
||||||
|
|
||||||
const previsualizar = (plantilla: string) =>
|
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',
|
'reportes.ver', 'reportes.exportar', 'caja.ver', 'caja.cerrar',
|
||||||
'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar',
|
'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar',
|
||||||
'notificaciones.ver', 'notificaciones.enviar', 'notificaciones.editar',
|
'notificaciones.ver', 'notificaciones.enviar', 'notificaciones.editar',
|
||||||
|
'formularios.ver', 'formularios.crear', 'formularios.editar', 'formularios.enviar',
|
||||||
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
|
'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 AG = ['admin', 'gerencia'];
|
||||||
const AD = ['admin'];
|
const AD = ['admin'];
|
||||||
const ALL = [...W, 'lectura'];
|
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([
|
const READ_PERM_KEYS = new Set([
|
||||||
'agenda.ver', 'agenda.todas',
|
'agenda.ver', 'agenda.todas',
|
||||||
@@ -37,6 +40,7 @@ const READ_PERM_KEYS = new Set([
|
|||||||
'ventas.reimprimir',
|
'ventas.reimprimir',
|
||||||
'whatsapp.ver',
|
'whatsapp.ver',
|
||||||
'notificaciones.ver',
|
'notificaciones.ver',
|
||||||
|
'formularios.ver',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
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,
|
'reportes.ver': ALL, 'reportes.exportar': ALL, 'caja.ver': ALL, 'caja.cerrar': MONEY,
|
||||||
'whatsapp.ver': W, 'whatsapp.responder': W, 'whatsapp.asignar': W,
|
'whatsapp.ver': W, 'whatsapp.responder': W, 'whatsapp.asignar': W,
|
||||||
'notificaciones.ver': ALL, 'notificaciones.enviar': W, 'notificaciones.editar': AG,
|
'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,
|
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect, useState, useCallback, useRef, useMemo } 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 Layout from '../components/Layout';
|
||||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||||
import AgendaPrint from '../components/AgendaPrint';
|
import AgendaPrint from '../components/AgendaPrint';
|
||||||
import PatientCombobox from '../components/PatientCombobox';
|
import PatientCombobox from '../components/PatientCombobox';
|
||||||
import RecargarSaldoModal from '../components/RecargarSaldoModal';
|
import RecargarSaldoModal from '../components/RecargarSaldoModal';
|
||||||
import ReciboPrint from '../components/ReciboPrint';
|
import ReciboPrint from '../components/ReciboPrint';
|
||||||
|
import EnviarFormularioModal from '../components/formulario/EnviarFormularioModal';
|
||||||
import BloqueosModal, { quickCloseOptions, type BloqueoQuickClose } from '../components/agenda/BloqueosModal';
|
import BloqueosModal, { quickCloseOptions, type BloqueoQuickClose } from '../components/agenda/BloqueosModal';
|
||||||
import { ListaEsperaPanel, ListaEsperaModal, whatsappLink } from '../components/agenda/ListaEspera';
|
import { ListaEsperaPanel, ListaEsperaModal, whatsappLink } from '../components/agenda/ListaEspera';
|
||||||
import { EstadoChecklist, CHECKLIST_STEPS, stepIndex } from '../components/agenda/EstadoChecklist';
|
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).
|
// sin depender de la búsqueda ni de la lista de citas cargadas).
|
||||||
const [pickedPatient, setPickedPatient] = useState<{ id: string; name: string } | null>(null);
|
const [pickedPatient, setPickedPatient] = useState<{ id: string; name: string } | null>(null);
|
||||||
const [detailApt, setDetailApt] = useState<Appointment | null>(null);
|
const [detailApt, setDetailApt] = useState<Appointment | null>(null);
|
||||||
|
const [enviarFormApt, setEnviarFormApt] = useState<Appointment | null>(null);
|
||||||
const [editMode, setEditMode] = useState(false);
|
const [editMode, setEditMode] = useState(false);
|
||||||
const [editForm, setEditForm] = useState({ date: '', time: '', doctor_id: '', notes: '' });
|
const [editForm, setEditForm] = useState({ date: '', time: '', doctor_id: '', notes: '' });
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
const [savingEdit, setSavingEdit] = useState(false);
|
||||||
@@ -3051,6 +3053,10 @@ const Agenda: FC = () => {
|
|||||||
<Button variant="outline" onClick={() => setEditMode(false)}>Volver</Button>
|
<Button variant="outline" onClick={() => setEditMode(false)}>Volver</Button>
|
||||||
<Button onClick={saveAptEdit} loading={savingEdit}>Guardar cambios</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
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -3277,6 +3283,15 @@ const Agenda: FC = () => {
|
|||||||
/>
|
/>
|
||||||
<ReciboPrint receipt={reciboApt} onClose={() => setReciboApt(null)} />
|
<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 */}
|
{/* Alta de paciente en la lista de espera */}
|
||||||
<ListaEsperaModal
|
<ListaEsperaModal
|
||||||
isOpen={listaModalOpen}
|
isOpen={listaModalOpen}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import TerminologiaPanel from '../components/TerminologiaPanel';
|
|||||||
import ReservasOnlinePanel from '../components/ReservasOnlinePanel';
|
import ReservasOnlinePanel from '../components/ReservasOnlinePanel';
|
||||||
import NotificacionesPanel from '../components/NotificacionesPanel';
|
import NotificacionesPanel from '../components/NotificacionesPanel';
|
||||||
import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPanel';
|
import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPanel';
|
||||||
|
import FormulariosPanel from '../components/FormulariosPanel';
|
||||||
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
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 { 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';
|
import { useAuth } from '../lib/auth';
|
||||||
@@ -26,6 +27,7 @@ const TABS = [
|
|||||||
{ key: 'reservas', label: 'Reservas en línea' },
|
{ key: 'reservas', label: 'Reservas en línea' },
|
||||||
{ key: 'notificaciones', label: 'Notificaciones' },
|
{ key: 'notificaciones', label: 'Notificaciones' },
|
||||||
{ key: 'notif_enviadas', label: 'Notificaciones enviadas' },
|
{ key: 'notif_enviadas', label: 'Notificaciones enviadas' },
|
||||||
|
{ key: 'formularios', label: 'Formularios' },
|
||||||
{ key: 'sucursales', label: 'Sucursales' },
|
{ key: 'sucursales', label: 'Sucursales' },
|
||||||
{ key: 'categorias', label: 'Categorías' },
|
{ key: 'categorias', label: 'Categorías' },
|
||||||
{ key: 'terminologia', label: 'Terminología' },
|
{ key: 'terminologia', label: 'Terminología' },
|
||||||
@@ -38,7 +40,7 @@ const TABS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Secciones que ve gerencia (admin del sistema ve todas).
|
// 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 = [
|
const SUBCATALOGOS = [
|
||||||
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
||||||
@@ -61,6 +63,7 @@ const Configuracion: FC = () => {
|
|||||||
auditoria: 'config.auditoria',
|
auditoria: 'config.auditoria',
|
||||||
notificaciones: 'notificaciones.editar',
|
notificaciones: 'notificaciones.editar',
|
||||||
notif_enviadas: 'notificaciones.ver',
|
notif_enviadas: 'notificaciones.ver',
|
||||||
|
formularios: 'formularios.ver',
|
||||||
};
|
};
|
||||||
const tabsVisibles = useMemo(
|
const tabsVisibles = useMemo(
|
||||||
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key)))
|
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key)))
|
||||||
@@ -400,6 +403,12 @@ const Configuracion: FC = () => {
|
|||||||
<NotificacionesEnviadasPanel />
|
<NotificacionesEnviadasPanel />
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card>
|
</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">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||||
<Card>
|
<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 {
|
import {
|
||||||
Search, Plus, Activity, ShoppingBag, User, Edit2, Phone, Mail, Calendar, Droplet, Heart,
|
Search, Plus, Activity, ShoppingBag, User, Edit2, Phone, Mail, Calendar, Droplet, Heart,
|
||||||
MapPin, Briefcase, Users, Baby, Stethoscope, FileText, AlertCircle, CheckCircle2, XCircle,
|
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';
|
} from 'lucide-react';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import PatientAdjuntos from '../components/PatientAdjuntos';
|
import PatientAdjuntos from '../components/PatientAdjuntos';
|
||||||
import EstadoCuentaPrint from '../components/EstadoCuentaPrint';
|
import EstadoCuentaPrint from '../components/EstadoCuentaPrint';
|
||||||
import RecargarSaldoModal from '../components/RecargarSaldoModal';
|
import RecargarSaldoModal from '../components/RecargarSaldoModal';
|
||||||
import ReciboPrint from '../components/ReciboPrint';
|
import ReciboPrint from '../components/ReciboPrint';
|
||||||
|
import FormulariosPaciente from '../components/FormulariosPaciente';
|
||||||
|
import EnviarFormularioModal from '../components/formulario/EnviarFormularioModal';
|
||||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -144,10 +146,12 @@ const Pacientes: FC = () => {
|
|||||||
const [recargaOpen, setRecargaOpen] = useState(false);
|
const [recargaOpen, setRecargaOpen] = useState(false);
|
||||||
const [reciboDeposito, setReciboDeposito] = useState<ReceiptData | null>(null);
|
const [reciboDeposito, setReciboDeposito] = useState<ReceiptData | null>(null);
|
||||||
const [historyLoading, setHistoryLoading] = useState(false);
|
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<
|
const [entryDetail, setEntryDetail] = useState<
|
||||||
{ kind: 'cita'; item: Appointment } | { kind: 'venta'; item: Sale } | { kind: 'visita'; item: Visita } | null
|
{ kind: 'cita'; item: Appointment } | { kind: 'venta'; item: Sale } | { kind: 'visita'; item: Visita } | null
|
||||||
>(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 [exporting, setExporting] = useState(false);
|
||||||
const [onlyIncomplete, setOnlyIncomplete] = useState(false);
|
const [onlyIncomplete, setOnlyIncomplete] = useState(false);
|
||||||
const [onlyVip, setOnlyVip] = useState(false);
|
const [onlyVip, setOnlyVip] = useState(false);
|
||||||
@@ -1002,6 +1006,7 @@ const Pacientes: FC = () => {
|
|||||||
{ key: 'clinical', label: 'Historia clínica' },
|
{ key: 'clinical', label: 'Historia clínica' },
|
||||||
{ key: 'timeline', label: 'Historial' },
|
{ key: 'timeline', label: 'Historial' },
|
||||||
{ key: 'docs', label: 'Documentos' },
|
{ key: 'docs', label: 'Documentos' },
|
||||||
|
{ key: 'formularios', label: 'Formularios' },
|
||||||
] as const).map(({ key, label }) => (
|
] as const).map(({ key, label }) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
@@ -1192,6 +1197,10 @@ const Pacientes: FC = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : tab === 'formularios' ? (
|
||||||
|
selected ? (
|
||||||
|
<FormulariosPaciente patientId={selected.id} patientName={selected.name} />
|
||||||
|
) : null
|
||||||
) : (
|
) : (
|
||||||
timelineItems.length > 0 ? (
|
timelineItems.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1253,6 +1262,20 @@ const Pacientes: FC = () => {
|
|||||||
onClose={() => setEntryDetail(null)}
|
onClose={() => setEntryDetail(null)}
|
||||||
title={entryDetail?.kind === 'cita' ? 'Detalle de la cita' : entryDetail?.kind === 'visita' ? 'Detalle de la visita' : 'Detalle de la venta'}
|
title={entryDetail?.kind === 'cita' ? 'Detalle de la cita' : entryDetail?.kind === 'visita' ? 'Detalle de la visita' : 'Detalle de la venta'}
|
||||||
maxWidth="lg"
|
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' && (
|
{entryDetail?.kind === 'cita' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -1399,6 +1422,16 @@ const Pacientes: FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ReciboPrint receipt={reciboDeposito} onClose={() => setReciboDeposito(null)} />
|
<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>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1071,6 +1071,8 @@ export interface NotificacionesSettings {
|
|||||||
plantilla_confirmacion: string;
|
plantilla_confirmacion: string;
|
||||||
plantilla_cumpleanos: string;
|
plantilla_cumpleanos: string;
|
||||||
plantilla_post_visita: string;
|
plantilla_post_visita: string;
|
||||||
|
/** Aviso de un formulario digital: usa {formulario} y {enlace}. */
|
||||||
|
plantilla_formulario: string;
|
||||||
whatsapp_numero: string;
|
whatsapp_numero: string;
|
||||||
email_remitente: 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_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_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_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: '',
|
whatsapp_numero: '',
|
||||||
email_remitente: '',
|
email_remitente: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Variables permitidas en las plantillas (espejo del backend). */
|
/** 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. */
|
/** Ventanas sugeridas para los chips de recordatorio. */
|
||||||
export const NOTIF_VENTANAS: { horas: number; label: string }[] = [
|
export const NOTIF_VENTANAS: { horas: number; label: string }[] = [
|
||||||
@@ -1224,6 +1227,114 @@ export interface BookingCita {
|
|||||||
telefono_contacto: string;
|
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 = {
|
export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
|
||||||
nombre: BRAND_DEFAULT.nombre,
|
nombre: BRAND_DEFAULT.nombre,
|
||||||
telefono: '',
|
telefono: '',
|
||||||
@@ -1926,6 +2037,88 @@ export const odooApi = {
|
|||||||
return data;
|
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 ----
|
// ---- Notificaciones y recordatorios de citas ----
|
||||||
async getNotificaciones(params?: Record<string, string | number>): Promise<NotificacionesResponse> {
|
async getNotificaciones(params?: Record<string, string | number>): Promise<NotificacionesResponse> {
|
||||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/notificaciones`, { params });
|
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 '
|
'Hola {nombre}, gracias por tu visita de {servicio} el {fecha}. Si tienes '
|
||||||
'dudas o quieres agendar de nuevo, llámanos al {telefono}.'
|
'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': '',
|
'whatsapp_numero': '',
|
||||||
'email_remitente': '',
|
'email_remitente': '',
|
||||||
}
|
}
|
||||||
|
|
||||||
NOTIF_CANALES = ('whatsapp', 'email', 'ambos')
|
NOTIF_CANALES = ('whatsapp', 'email', 'ambos')
|
||||||
NOTIF_VARIABLES = ('nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor',
|
NOTIF_VARIABLES = ('nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor',
|
||||||
'sucursal', 'telefono')
|
'sucursal', 'telefono', 'enlace', 'formulario')
|
||||||
NOTIF_PLANTILLAS = ('plantilla_recordatorio', 'plantilla_confirmacion',
|
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_HORAS_MAX = 168 # 1 semana
|
||||||
NOTIF_PLANTILLA_MAX = 1000 # caracteres por plantilla
|
NOTIF_PLANTILLA_MAX = 1000 # caracteres por plantilla
|
||||||
NOTIF_TEXTO_MAX = 120 # whatsapp_numero / email_remitente
|
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'),
|
('cumpleanos', 'Cumpleaños'),
|
||||||
('post_visita', 'Post-visita'),
|
('post_visita', 'Post-visita'),
|
||||||
('recuperacion', 'Recuperación'),
|
('recuperacion', 'Recuperación'),
|
||||||
|
('formulario', 'Formulario'),
|
||||||
('manual', 'Manual'),
|
('manual', 'Manual'),
|
||||||
]
|
]
|
||||||
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')]
|
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')]
|
||||||
@@ -64,6 +65,8 @@ PLANTILLA_POR_TIPO = {
|
|||||||
# bloque `notificaciones` solo define cuatro plantillas (recordatorio,
|
# bloque `notificaciones` solo define cuatro plantillas (recordatorio,
|
||||||
# confirmación, cumpleaños y post-visita).
|
# confirmación, cumpleaños y post-visita).
|
||||||
'recuperacion': 'plantilla_recordatorio',
|
'recuperacion': 'plantilla_recordatorio',
|
||||||
|
# El aviso de un formulario digital usa su propia plantilla con {enlace}.
|
||||||
|
'formulario': 'plantilla_formulario',
|
||||||
'manual': 'plantilla_recordatorio',
|
'manual': 'plantilla_recordatorio',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,12 +132,13 @@ class SkeenNotificacionService(models.AbstractModel):
|
|||||||
return clinic_get_notificaciones(self.env)
|
return clinic_get_notificaciones(self.env)
|
||||||
|
|
||||||
@api.model
|
@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.
|
"""Sustituye las variables de una plantilla con datos reales.
|
||||||
|
|
||||||
Los nombres de los términos (`{cita}`) salen del glosario vigente; las
|
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
|
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()
|
cfg = cfg or self._cfg()
|
||||||
settings = clinic_get_settings(self.env)
|
settings = clinic_get_settings(self.env)
|
||||||
@@ -166,6 +170,10 @@ class SkeenNotificacionService(models.AbstractModel):
|
|||||||
contexto['fecha'] = fields.Date.today().strftime('%d/%m/%Y')
|
contexto['fecha'] = fields.Date.today().strftime('%d/%m/%Y')
|
||||||
contexto['sucursal'] = self._sucursal_default()
|
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 '')
|
texto = str(plantilla or '')
|
||||||
for clave, valor in contexto.items():
|
for clave, valor in contexto.items():
|
||||||
texto = texto.replace('{%s}' % clave, str(valor or ''))
|
texto = texto.replace('{%s}' % clave, str(valor or ''))
|
||||||
@@ -221,12 +229,14 @@ class SkeenNotificacionService(models.AbstractModel):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@api.model
|
@api.model
|
||||||
def encolar(self, cita=None, tipo='recordatorio', cuando=None, canal=None,
|
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).
|
"""Crea las notificaciones pendientes de una cita (una por canal).
|
||||||
|
|
||||||
Con `activo=False` no hace nada. No duplica: si ya existe la misma
|
Con `activo=False` no hace nada. No duplica: si ya existe la misma
|
||||||
`clave_unica` (misma cita + tipo + ventana + canal) se salta. Devuelve el
|
`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()
|
Notif = self.env['skeen.notificacion'].sudo()
|
||||||
cfg = self._cfg()
|
cfg = self._cfg()
|
||||||
@@ -239,7 +249,8 @@ class SkeenNotificacionService(models.AbstractModel):
|
|||||||
plantilla = cfg.get(PLANTILLA_POR_TIPO.get(tipo, 'plantilla_recordatorio')) or ''
|
plantilla = cfg.get(PLANTILLA_POR_TIPO.get(tipo, 'plantilla_recordatorio')) or ''
|
||||||
if not plantilla.strip():
|
if not plantilla.strip():
|
||||||
return Notif
|
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:
|
if asunto is None:
|
||||||
asunto = self._asunto(tipo)
|
asunto = self._asunto(tipo)
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,12 @@ PERMISSION_GROUPS = [
|
|||||||
('notificaciones.enviar', 'Enviar y reintentar notificaciones'),
|
('notificaciones.enviar', 'Enviar y reintentar notificaciones'),
|
||||||
('notificaciones.editar', 'Configurar recordatorios y plantillas'),
|
('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 y configuración', [
|
||||||
('config.usuarios', 'Administrar usuarios'),
|
('config.usuarios', 'Administrar usuarios'),
|
||||||
('config.roles', 'Administrar roles y menús'),
|
('config.roles', 'Administrar roles y menús'),
|
||||||
@@ -116,6 +122,7 @@ READ_PERM_KEYS = (
|
|||||||
'ventas.reimprimir',
|
'ventas.reimprimir',
|
||||||
'whatsapp.ver',
|
'whatsapp.ver',
|
||||||
'notificaciones.ver',
|
'notificaciones.ver',
|
||||||
|
'formularios.ver',
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -197,6 +204,12 @@ DEFAULT_PERM_ROLES = {
|
|||||||
'notificaciones.ver': ALL,
|
'notificaciones.ver': ALL,
|
||||||
'notificaciones.enviar': W,
|
'notificaciones.enviar': W,
|
||||||
'notificaciones.editar': AG,
|
'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
|
# Usuarios y configuración
|
||||||
'config.usuarios': AG,
|
'config.usuarios': AG,
|
||||||
'config.roles': AG,
|
'config.roles': AG,
|
||||||
|
|||||||
Reference in New Issue
Block a user