fase 2.5: formularios digitales y consentimientos (modulo skeen_formularios: plantillas con 12 tipos de campo, versionado, respuestas con firma PNG y fotos antes/despues, portal publico con token HMAC y limite de subida, historial por paciente, permisos y notificacion de envio)
This commit is contained in:
@@ -35,6 +35,7 @@ import MisVentas from './pages/MisVentas';
|
||||
import Login from './pages/Login';
|
||||
import Reservar from './pages/Reservar';
|
||||
import ReservarCita from './pages/ReservarCita';
|
||||
import FormularioPublico from './pages/FormularioPublico';
|
||||
import './App.css';
|
||||
|
||||
const FullScreenLoader: FC = () => (
|
||||
@@ -140,6 +141,8 @@ const App: FC = () => {
|
||||
{/* Portal público de reservas: FUERA del gate de autenticación. */}
|
||||
<Route path="/reservar" element={<Reservar />} />
|
||||
<Route path="/reservar/cita/:token" element={<ReservarCita />} />
|
||||
{/* Portal público de formularios: token HMAC como credencial. */}
|
||||
<Route path="/formulario/:token" element={<FormularioPublico />} />
|
||||
<Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} />
|
||||
<Route path="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
|
||||
<Route path="/mis-ventas" element={<RequireAuth href="/mis-ventas"><MisVentas /></RequireAuth>} />
|
||||
|
||||
264
frontend/src/components/FormulariosPaciente.tsx
Normal file
264
frontend/src/components/FormulariosPaciente.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Camera, ClipboardList, Clock, FileSignature, Send } from 'lucide-react';
|
||||
import { Badge, Button, Modal, Skeleton, EmptyState, toast } from './ui';
|
||||
import { odooApi, type FormularioFoto, type FormularioRespuesta } from '../services/odoo';
|
||||
import EnviarFormularioModal from './formulario/EnviarFormularioModal';
|
||||
import { MOMENTO_LABEL, valorLegible } from './formulario/tipos';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
|
||||
// Sección "Formularios" del expediente del paciente: respuestas (con quién y
|
||||
// cuándo), pendientes marcados, detalle con firma y fotos, y la galería de
|
||||
// fotos antes/después agrupada por fecha.
|
||||
|
||||
interface Props {
|
||||
patientId: number;
|
||||
patientName?: string;
|
||||
}
|
||||
|
||||
const estadoVariant = (estado: string) =>
|
||||
estado === 'llenado' ? 'success' : estado === 'enviado' ? 'warning' : estado === 'cancelado' ? 'danger' : 'default';
|
||||
|
||||
const fechaCorta = (s: string | null) => {
|
||||
if (!s) return '—';
|
||||
const d = new Date(`${s.slice(0, 10)}T12:00:00`);
|
||||
if (Number.isNaN(d.getTime())) return s.slice(0, 10);
|
||||
return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
};
|
||||
|
||||
const FormulariosPaciente: FC<Props> = ({ patientId, patientName = '' }) => {
|
||||
const { can } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const [respuestas, setRespuestas] = useState<FormularioRespuesta[]>([]);
|
||||
const [fotos, setFotos] = useState<FormularioFoto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detalle, setDetalle] = useState<FormularioRespuesta | null>(null);
|
||||
const [enviarOpen, setEnviarOpen] = useState(false);
|
||||
|
||||
const cargar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [resR, resF] = await Promise.all([
|
||||
odooApi.getFormularioRespuestas({ partner_id: patientId }),
|
||||
odooApi.getFormularioFotos({ partner_id: patientId }),
|
||||
]);
|
||||
if (resR.status === 'success') setRespuestas(resR.respuestas);
|
||||
if (resF.status === 'success') setFotos(resF.fotos);
|
||||
} catch {
|
||||
toast.error('No se pudieron cargar los formularios');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [patientId]);
|
||||
|
||||
useEffect(() => { cargar(); }, [cargar]);
|
||||
|
||||
const pendientes = respuestas.filter((r) => r.estado === 'enviado');
|
||||
|
||||
const galeria = useMemo(() => {
|
||||
const grupos = new Map<string, FormularioFoto[]>();
|
||||
for (const f of fotos) {
|
||||
const dia = (f.create_date || '').slice(0, 10) || 'sin-fecha';
|
||||
if (!grupos.has(dia)) grupos.set(dia, []);
|
||||
grupos.get(dia)!.push(f);
|
||||
}
|
||||
return Array.from(grupos.entries()).sort((a, b) => (a[0] < b[0] ? 1 : -1));
|
||||
}, [fotos]);
|
||||
|
||||
const abrirDetalle = async (id: number) => {
|
||||
try {
|
||||
const res = await odooApi.getFormularioRespuesta(id);
|
||||
if (res.status === 'success') setDetalle(res.respuesta);
|
||||
} catch {
|
||||
toast.error('No se pudo abrir el formulario');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h4 className="flex items-center text-sm font-semibold text-theme-heading">
|
||||
<FileSignature size={16} className="mr-2 text-theme-muted" /> Formularios y consentimientos
|
||||
</h4>
|
||||
{can('formularios.enviar') && (
|
||||
<Button size="sm" variant="outline" onClick={() => setEnviarOpen(true)}>
|
||||
<Send size={14} className="mr-1.5" /> Enviar formulario
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pendientes.length > 0 && (
|
||||
<div className="rounded-xl border border-amber-300 bg-amber-50 p-3">
|
||||
<p className="flex items-center text-xs font-medium text-amber-800">
|
||||
<Clock size={13} className="mr-1.5" /> {pendientes.length} formulario{pendientes.length > 1 ? 's' : ''} pendiente{pendientes.length > 1 ? 's' : ''} de llenar
|
||||
</p>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{pendientes.map((r) => (
|
||||
<li key={r.id} className="text-xs text-amber-800">
|
||||
{r.plantilla} · enviado {fechaCorta(r.enviado_at || r.create_date)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={3} className="h-12 w-full" />
|
||||
) : respuestas.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin formularios"
|
||||
subtitle={`Este ${term('patient_singular').toLowerCase()} aún no tiene formularios ni consentimientos.`}
|
||||
icon={<ClipboardList size={26} />}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{respuestas.map((r) => (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => abrirDetalle(r.id)}
|
||||
className={`w-full rounded-xl border p-3 text-left transition hover:shadow-card ${
|
||||
r.estado === 'enviado' ? 'border-amber-300 bg-amber-50/50' : 'border-theme-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-theme-heading">{r.plantilla}</p>
|
||||
<Badge variant={estadoVariant(r.estado)}>{r.estado_label || r.estado}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-theme-muted">
|
||||
{r.name} · {fechaCorta(r.llenado_at || r.enviado_at || r.create_date)}
|
||||
{' · '}
|
||||
{r.creado_por_empleado ? 'Llenado por el personal' : `Llenado por el ${term('patient_singular').toLowerCase()}`}
|
||||
{r.tiene_firma ? ' · firmado' : ''}
|
||||
{r.fotos_count > 0 ? ` · ${r.fotos_count} foto${r.fotos_count > 1 ? 's' : ''}` : ''}
|
||||
</p>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{galeria.length > 0 && (
|
||||
<section>
|
||||
<h4 className="mb-2 flex items-center text-sm font-semibold text-theme-heading">
|
||||
<Camera size={16} className="mr-2 text-theme-muted" /> Galería antes / después
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
{galeria.map(([dia, items]) => (
|
||||
<div key={dia}>
|
||||
<p className="mb-1.5 text-xs font-medium text-theme-muted">{fechaCorta(dia)}</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{items.map((f) => (
|
||||
<figure key={f.id} className="relative">
|
||||
<img
|
||||
src={odooApi.formularioArchivoUrl(f.url)}
|
||||
alt={f.name}
|
||||
className="h-28 w-full rounded-xl border border-theme-border object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption className="absolute bottom-1 left-1 rounded-md bg-theme-surface/90 px-1.5 py-0.5 text-[10px] font-medium text-theme-heading">
|
||||
{MOMENTO_LABEL[f.kind] || f.kind}
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
isOpen={!!detalle}
|
||||
onClose={() => setDetalle(null)}
|
||||
title={detalle ? `${detalle.plantilla} — ${detalle.name}` : 'Formulario'}
|
||||
maxWidth="2xl"
|
||||
>
|
||||
{detalle && (
|
||||
<div className="max-h-[70vh] space-y-4 overflow-y-auto pr-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-theme-muted">
|
||||
<Badge variant={estadoVariant(detalle.estado)}>{detalle.estado_label || detalle.estado}</Badge>
|
||||
<span>{fechaCorta(detalle.llenado_at || detalle.enviado_at || detalle.create_date)}</span>
|
||||
<span>· {detalle.creado_por_empleado ? 'Llenado por el personal' : `Llenado por el ${term('patient_singular').toLowerCase()}`}</span>
|
||||
</div>
|
||||
|
||||
{(detalle.campos || []).length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{(detalle.campos || [])
|
||||
.filter((c) => !['firma', 'foto', 'archivo'].includes(c.tipo))
|
||||
.map((c) => (
|
||||
<div key={c.clave} className="rounded-xl bg-theme-bg p-3">
|
||||
<p className="text-xs text-theme-muted">{c.etiqueta}</p>
|
||||
<p className="text-sm text-theme-heading whitespace-pre-wrap">
|
||||
{valorLegible(c, detalle.respuestas?.[c.clave])}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detalle.texto_legal && (
|
||||
<details className="rounded-xl bg-theme-bg p-3">
|
||||
<summary className="cursor-pointer text-xs font-medium text-theme-muted">Texto legal aceptado</summary>
|
||||
<p className="mt-2 text-xs leading-relaxed text-theme-muted whitespace-pre-wrap">{detalle.texto_legal}</p>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{detalle.tiene_firma && detalle.firma_url && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs text-theme-muted">Firma{detalle.firma_nombre ? ` de ${detalle.firma_nombre}` : ''}</p>
|
||||
<img
|
||||
src={odooApi.formularioArchivoUrl(detalle.firma_url)}
|
||||
alt="Firma"
|
||||
className="max-h-40 rounded-xl border border-theme-border bg-white p-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(detalle.fotos || []).length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs text-theme-muted">Fotos y archivos</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{(detalle.fotos || []).map((f) => (
|
||||
<figure key={f.id} className="relative">
|
||||
{f.mimetype.startsWith('image/') ? (
|
||||
<img
|
||||
src={odooApi.formularioArchivoUrl(f.url)}
|
||||
alt={f.name}
|
||||
className="h-32 w-full rounded-xl border border-theme-border object-cover"
|
||||
/>
|
||||
) : (
|
||||
<a
|
||||
href={odooApi.formularioArchivoUrl(f.url)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex h-32 items-center justify-center rounded-xl border border-theme-border bg-theme-bg px-2 text-center text-xs text-theme-heading"
|
||||
>
|
||||
{f.name}
|
||||
</a>
|
||||
)}
|
||||
<figcaption className="absolute bottom-1 left-1 rounded-md bg-theme-surface/90 px-1.5 py-0.5 text-[10px] font-medium text-theme-heading">
|
||||
{MOMENTO_LABEL[f.kind] || f.kind}
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<EnviarFormularioModal
|
||||
isOpen={enviarOpen}
|
||||
onClose={() => setEnviarOpen(false)}
|
||||
patientId={patientId}
|
||||
patientName={patientName}
|
||||
onSent={cargar}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormulariosPaciente;
|
||||
438
frontend/src/components/FormulariosPanel.tsx
Normal file
438
frontend/src/components/FormulariosPanel.tsx
Normal file
@@ -0,0 +1,438 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ChevronDown, ChevronUp, Eye, FileSignature, GripVertical, Plus, Save, Send, Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Badge, Button, Card, EmptyState, Input, Modal, Select, Skeleton, TextArea, toast } from './ui';
|
||||
import { odooApi, type FormularioCampo, type FormularioCampoTipo, type FormularioPlantilla } from '../services/odoo';
|
||||
import FormularioCampos from './formulario/FormularioCampos';
|
||||
import EnviarFormularioModal from './formulario/EnviarFormularioModal';
|
||||
import { CAMPO_TIPOS, PLANTILLA_TIPOS, TIPO_LABEL, MOMENTO_LABEL } from './formulario/tipos';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
|
||||
// Configuración › Formularios: constructor de plantillas (campos, texto legal,
|
||||
// firma/foto), vista previa tal como la verá el paciente y envío a un paciente.
|
||||
// Sin plantillas no hay nada que mostrar más allá del estado vacío.
|
||||
|
||||
const slug = (texto: string): string =>
|
||||
texto
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 40);
|
||||
|
||||
const campoNuevo = (campos: FormularioCampo[], tipo: FormularioCampoTipo = 'texto'): FormularioCampo => {
|
||||
const base = 'campo';
|
||||
let n = campos.length + 1;
|
||||
const claves = new Set(campos.map((c) => c.clave));
|
||||
while (claves.has(`${base}_${n}`)) n += 1;
|
||||
return { clave: `${base}_${n}`, etiqueta: '', tipo, obligatorio: false, ayuda: '', opciones: [] };
|
||||
};
|
||||
|
||||
const FormulariosPanel: FC = () => {
|
||||
const { can } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const [plantillas, setPlantillas] = useState<FormularioPlantilla[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editando, setEditando] = useState<FormularioPlantilla | 'nueva' | null>(null);
|
||||
const [enviarOpen, setEnviarOpen] = useState(false);
|
||||
|
||||
const cargar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await odooApi.getFormularioPlantillas({ includeInactive: true });
|
||||
if (res.status === 'success') setPlantillas(res.plantillas);
|
||||
} catch {
|
||||
toast.error('No se pudieron cargar las plantillas');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { cargar(); }, [cargar]);
|
||||
|
||||
const toggleActivo = async (p: FormularioPlantilla) => {
|
||||
try {
|
||||
await odooApi.updateFormularioPlantilla(p.id, { activo: !p.activo });
|
||||
setPlantillas((prev) => prev.map((it) => (it.id === p.id ? { ...it, activo: !it.activo } : it)));
|
||||
} catch {
|
||||
toast.error('No se pudo cambiar el estado');
|
||||
}
|
||||
};
|
||||
|
||||
const eliminar = async (p: FormularioPlantilla) => {
|
||||
if (!window.confirm(`¿Eliminar la plantilla "${p.name}"?`)) return;
|
||||
try {
|
||||
const res = await odooApi.deleteFormularioPlantilla(p.id);
|
||||
toast.success(res.message || 'Plantilla eliminada');
|
||||
cargar();
|
||||
} catch {
|
||||
toast.error('No se pudo eliminar la plantilla');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<FileSignature size={20} className="mt-0.5 shrink-0 text-theme-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-heading text-xl text-theme-heading">Formularios digitales</h3>
|
||||
<p className="text-sm text-theme-muted">
|
||||
Consentimientos, autorizaciones, encuestas y notas que el {term('patient_singular').toLowerCase()} llena desde su celular
|
||||
con firma y fotos, o que el personal llena en recepción. Mientras no existan plantillas,
|
||||
no aparece nada nuevo para el {term('patient_singular').toLowerCase()}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
{can('formularios.enviar') && (
|
||||
<Button variant="outline" onClick={() => setEnviarOpen(true)}>
|
||||
<Send size={15} className="mr-1.5" /> Enviar
|
||||
</Button>
|
||||
)}
|
||||
{can('formularios.crear') && (
|
||||
<Button onClick={() => setEditando('nueva')}>
|
||||
<Plus size={15} className="mr-1.5" /> Nueva plantilla
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={3} className="h-16 w-full" />
|
||||
) : plantillas.length === 0 ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<EmptyState
|
||||
title="Aún no hay plantillas"
|
||||
subtitle="Crea la primera plantilla de consentimiento o formulario. Hasta entonces no cambia nada en el sistema."
|
||||
icon={<FileSignature size={28} />}
|
||||
actionLabel={can('formularios.crear') ? 'Nueva plantilla' : undefined}
|
||||
onAction={can('formularios.crear') ? () => setEditando('nueva') : undefined}
|
||||
/>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<th className="p-3 text-left text-xs font-medium uppercase text-theme-muted">Plantilla</th>
|
||||
<th className="p-3 text-left text-xs font-medium uppercase text-theme-muted">Tipo</th>
|
||||
<th className="hidden p-3 text-left text-xs font-medium uppercase text-theme-muted sm:table-cell">Campos</th>
|
||||
<th className="p-3 text-left text-xs font-medium uppercase text-theme-muted">Estado</th>
|
||||
<th className="p-3 text-right text-xs font-medium uppercase text-theme-muted">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{plantillas.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3">
|
||||
<p className="text-sm font-medium text-theme-heading">{p.name}</p>
|
||||
<p className="text-xs text-theme-muted">v{p.version} · {p.respuestas_count} respuesta{p.respuestas_count === 1 ? '' : 's'}</p>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-theme-muted">{p.tipo_label || p.tipo}</td>
|
||||
<td className="hidden p-3 text-sm text-theme-muted sm:table-cell">{p.campos.length}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={p.activo ? 'success' : 'default'}>{p.activo ? 'Activa' : 'Inactiva'}</Badge>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{can('formularios.editar') && (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditando(p)}>Editar</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => toggleActivo(p)}>
|
||||
{p.activo ? 'Desactivar' : 'Activar'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => eliminar(p)} title="Eliminar">
|
||||
<Trash2 size={14} className="text-rose-500" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editando && (
|
||||
<EditorPlantilla
|
||||
plantilla={editando === 'nueva' ? null : editando}
|
||||
canSave={can('formularios.crear') || can('formularios.editar')}
|
||||
onClose={() => setEditando(null)}
|
||||
onSaved={() => { setEditando(null); cargar(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EnviarFormularioModal isOpen={enviarOpen} onClose={() => setEnviarOpen(false)} onSent={cargar} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EditorPlantilla: FC<{
|
||||
plantilla: FormularioPlantilla | null;
|
||||
canSave: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}> = ({ plantilla, canSave, onClose, onSaved }) => {
|
||||
const { term } = useGlosario();
|
||||
const [name, setName] = useState(plantilla?.name || '');
|
||||
const [tipo, setTipo] = useState(plantilla?.tipo || 'consentimiento');
|
||||
const [descripcion, setDescripcion] = useState(plantilla?.descripcion || '');
|
||||
const [duracion, setDuracion] = useState(plantilla?.duracion_estimada_min ?? 5);
|
||||
const [requiereFirma, setRequiereFirma] = useState(plantilla?.requiere_firma ?? false);
|
||||
const [requiereFoto, setRequiereFoto] = useState(plantilla?.requiere_foto ?? false);
|
||||
const [textoLegal, setTextoLegal] = useState(plantilla?.texto_legal || '');
|
||||
const [campos, setCampos] = useState<FormularioCampo[]>(plantilla?.campos || []);
|
||||
const [guardando, setGuardando] = useState(false);
|
||||
const [preview, setPreview] = useState(false);
|
||||
const previewValores = useMemo(() => ({}), []);
|
||||
|
||||
const actualizar = (i: number, patch: Partial<FormularioCampo>) =>
|
||||
setCampos((prev) => prev.map((c, j) => (j === i ? { ...c, ...patch } : c)));
|
||||
|
||||
const mover = (i: number, dir: -1 | 1) =>
|
||||
setCampos((prev) => {
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= prev.length) return prev;
|
||||
const copia = [...prev];
|
||||
[copia[i], copia[j]] = [copia[j], copia[i]];
|
||||
return copia;
|
||||
});
|
||||
|
||||
const quitar = (i: number) => setCampos((prev) => prev.filter((_, j) => j !== i));
|
||||
|
||||
const agregar = (t: FormularioCampoTipo) => setCampos((prev) => [...prev, campoNuevo(prev, t)]);
|
||||
|
||||
const guardar = async () => {
|
||||
if (!name.trim()) { toast.error('Ponle un nombre a la plantilla'); return; }
|
||||
if (campos.some((c) => !c.clave.trim())) { toast.error('Cada campo necesita una clave'); return; }
|
||||
const claves = campos.map((c) => c.clave.trim());
|
||||
if (new Set(claves).size !== claves.length) { toast.error('Hay claves repetidas'); return; }
|
||||
if (campos.some((c) => (c.tipo === 'seleccion' || c.tipo === 'multi_seleccion') && c.opciones.length === 0)) {
|
||||
toast.error('Los campos de opciones necesitan al menos una opción');
|
||||
return;
|
||||
}
|
||||
setGuardando(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
tipo,
|
||||
descripcion,
|
||||
duracion_estimada_min: duracion,
|
||||
requiere_firma: requiereFirma,
|
||||
requiere_foto: requiereFoto,
|
||||
texto_legal: textoLegal,
|
||||
campos: campos.map((c) => ({
|
||||
clave: c.clave.trim(),
|
||||
etiqueta: (c.etiqueta || c.clave).trim(),
|
||||
tipo: c.tipo,
|
||||
obligatorio: c.obligatorio,
|
||||
ayuda: c.ayuda || '',
|
||||
opciones: c.tipo === 'seleccion' || c.tipo === 'multi_seleccion' ? c.opciones : [],
|
||||
...(c.tipo === 'foto' || c.tipo === 'archivo' ? { momento: c.momento || 'otro' } : {}),
|
||||
})),
|
||||
};
|
||||
if (plantilla) await odooApi.updateFormularioPlantilla(plantilla.id, payload);
|
||||
else await odooApi.createFormularioPlantilla(payload);
|
||||
toast.success('Plantilla guardada');
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo guardar la plantilla');
|
||||
} finally {
|
||||
setGuardando(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={plantilla ? `Editar plantilla — ${plantilla.name}` : 'Nueva plantilla'}
|
||||
maxWidth="full"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setPreview(true)}>
|
||||
<Eye size={15} className="mr-1.5" /> Vista previa
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onClose}>Cancelar</Button>
|
||||
{canSave && (
|
||||
<Button loading={guardando} onClick={guardar}>
|
||||
<Save size={16} className="mr-2" /> Guardar
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="max-h-[72vh] space-y-5 overflow-y-auto pr-1">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Input label="Nombre" value={name} onChange={(e) => setName(e.target.value)} placeholder="Consentimiento de láser" />
|
||||
<Select
|
||||
label="Tipo"
|
||||
value={tipo}
|
||||
onChange={(e) => setTipo(e.target.value as typeof tipo)}
|
||||
options={PLANTILLA_TIPOS}
|
||||
/>
|
||||
</div>
|
||||
<TextArea label={`Descripción para el ${term('patient_singular').toLowerCase()} (opcional)`} value={descripcion} rows={2}
|
||||
onChange={(e) => setDescripcion(e.target.value)} />
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Input label="Duración estimada (min)" type="number" min={0} max={120}
|
||||
value={String(duracion)} onChange={(e) => setDuracion(Number(e.target.value) || 0)} />
|
||||
<Toggle label="Requiere firma" checked={requiereFirma} onChange={setRequiereFirma} />
|
||||
<Toggle label="Requiere foto" checked={requiereFoto} onChange={setRequiereFoto} />
|
||||
</div>
|
||||
<TextArea
|
||||
label="Texto legal / consentimiento"
|
||||
value={textoLegal}
|
||||
rows={5}
|
||||
onChange={(e) => setTextoLegal(e.target.value)}
|
||||
placeholder={`Se muestra al ${term('patient_singular').toLowerCase()} antes de firmar. Acepta saltos de línea.`}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-theme-heading">Campos del formulario</p>
|
||||
<span className="text-xs text-theme-muted">{campos.length} campo{campos.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{campos.map((c, i) => (
|
||||
<div key={i} className="rounded-xl border border-theme-border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical size={14} className="shrink-0 text-theme-muted" />
|
||||
<div className="grid flex-1 grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<Input
|
||||
label="Etiqueta"
|
||||
value={c.etiqueta}
|
||||
placeholder="Nombre completo"
|
||||
onChange={(e) => {
|
||||
const valor = e.target.value;
|
||||
setCampos((prev) => prev.map((x, j) => {
|
||||
if (j !== i) return x;
|
||||
const auto = !x.etiqueta || x.clave === slug(x.etiqueta);
|
||||
return { ...x, etiqueta: valor, clave: auto ? (slug(valor) || x.clave) : x.clave };
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Tipo"
|
||||
value={c.tipo}
|
||||
onChange={(e) => actualizar(i, { tipo: e.target.value as FormularioCampoTipo })}
|
||||
options={CAMPO_TIPOS.map((t) => ({ value: t.tipo, label: t.label }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
<button type="button" onClick={() => mover(i, -1)} className="rounded p-1 text-theme-muted hover:bg-theme-bg" title="Subir">
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => mover(i, 1)} className="rounded p-1 text-theme-muted hover:bg-theme-bg" title="Bajar">
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => quitar(i)} className="rounded p-1 text-rose-500 hover:bg-theme-bg" title="Quitar">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<Input label="Ayuda (opcional)" value={c.ayuda} placeholder="Texto de apoyo bajo el campo"
|
||||
onChange={(e) => actualizar(i, { ayuda: e.target.value })} />
|
||||
<Input label="Clave (identificador)" value={c.clave}
|
||||
onChange={(e) => actualizar(i, { clave: slug(e.target.value) || e.target.value })} />
|
||||
</div>
|
||||
{(c.tipo === 'seleccion' || c.tipo === 'multi_seleccion') && (
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
label="Opciones (separadas por coma)"
|
||||
value={c.opciones.join(', ')}
|
||||
placeholder="Seca, Mixta, Grasa"
|
||||
onChange={(e) => actualizar(i, {
|
||||
opciones: e.target.value.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(c.tipo === 'foto' || c.tipo === 'archivo') && (
|
||||
<div className="mt-2 sm:max-w-xs">
|
||||
<Select
|
||||
label="Momento (galería del expediente)"
|
||||
value={c.momento || 'otro'}
|
||||
onChange={(e) => actualizar(i, { momento: e.target.value as FormularioCampo['momento'] })}
|
||||
options={Object.entries(MOMENTO_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<label className="mt-2 flex cursor-pointer items-center gap-2 text-sm text-theme-muted">
|
||||
<input type="checkbox" checked={c.obligatorio}
|
||||
onChange={(e) => actualizar(i, { obligatorio: e.target.checked })}
|
||||
className="h-4 w-4 accent-theme-accent" />
|
||||
Obligatorio
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
{campos.length === 0 && (
|
||||
<p className="rounded-xl border border-dashed border-theme-border-strong p-4 text-center text-sm text-theme-muted">
|
||||
Sin campos todavía. Agrega el primero.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{(['texto', 'texto_largo', 'numero', 'fecha', 'seleccion', 'multi_seleccion', 'checkbox', 'escala', 'firma', 'foto', 'archivo', 'consentimiento'] as FormularioCampoTipo[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => agregar(t)}
|
||||
className="rounded-full bg-theme-accent-bg px-3 py-1.5 text-xs font-medium text-theme-heading hover:bg-theme-bg"
|
||||
>
|
||||
<Plus size={11} className="mr-1 inline" /> {TIPO_LABEL[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={preview}
|
||||
onClose={() => setPreview(false)}
|
||||
title={`Vista previa (tal como la ve el ${term('patient_singular').toLowerCase()})`}
|
||||
maxWidth="lg"
|
||||
>
|
||||
<div className="max-h-[70vh] overflow-y-auto">
|
||||
<p className="mb-3 rounded-xl bg-theme-accent-bg p-3 text-sm font-medium text-theme-heading">{name || 'Formulario'}</p>
|
||||
<FormularioCampos
|
||||
campos={campos}
|
||||
valores={previewValores}
|
||||
onChange={() => {}}
|
||||
firma={null}
|
||||
onFirma={() => {}}
|
||||
firmaNombre=""
|
||||
onFirmaNombre={() => {}}
|
||||
fotos={[]}
|
||||
setFotos={() => {}}
|
||||
textoLegal={textoLegal}
|
||||
requiereFirma={requiereFirma}
|
||||
requiereFoto={requiereFoto}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void }> = ({ label, checked, onChange }) => (
|
||||
<label className="flex cursor-pointer items-center gap-2 self-end rounded-xl border border-theme-border px-3 py-3 text-sm text-theme-heading">
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} className="h-4 w-4 accent-theme-accent" />
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
|
||||
export default FormulariosPanel;
|
||||
@@ -23,6 +23,7 @@ const TIPOS_PLANTILLA: { key: keyof NotificacionesSettings; label: string }[] =
|
||||
{ key: 'plantilla_confirmacion', label: 'Confirmación' },
|
||||
{ key: 'plantilla_cumpleanos', label: 'Cumpleaños' },
|
||||
{ key: 'plantilla_post_visita', label: 'Post-visita' },
|
||||
{ key: 'plantilla_formulario', label: 'Formulario digital' },
|
||||
];
|
||||
|
||||
const NotificacionesPanel: FC<Props> = ({ settings, onSaved }) => {
|
||||
@@ -51,6 +52,8 @@ const NotificacionesPanel: FC<Props> = ({ settings, onSaved }) => {
|
||||
doctor: 'Dra. García',
|
||||
sucursal: settings.nombre || 'Sucursal Centro',
|
||||
telefono: settings.telefono || '+52 661 000 0000',
|
||||
enlace: 'https://clinica.mx/formulario/abc123',
|
||||
formulario: 'Consentimiento informado',
|
||||
}), [term, settings.nombre, settings.telefono]);
|
||||
|
||||
const previsualizar = (plantilla: string) =>
|
||||
|
||||
354
frontend/src/components/formulario/CampoFormulario.tsx
Normal file
354
frontend/src/components/formulario/CampoFormulario.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import type { FC } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { Camera, FileText, Paperclip, X } from 'lucide-react';
|
||||
import type { FormularioCampo } from '../../services/odoo';
|
||||
import FirmaCanvas from './FirmaCanvas';
|
||||
|
||||
// Renderiza un campo del formulario según su tipo. Pensado mobile-first: áreas
|
||||
// táctiles grandes, radios en vez de selects nativos y la cámara del celular
|
||||
// para los campos de foto.
|
||||
|
||||
export interface FotoLocal {
|
||||
data_url: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
campo: FormularioCampo;
|
||||
valor: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
firma?: string | null;
|
||||
onFirma?: (v: string | null) => void;
|
||||
fotos?: FotoLocal[];
|
||||
onFotos?: (items: FotoLocal[]) => void;
|
||||
onQuitarFoto?: (index: number) => void;
|
||||
textoLegal?: string;
|
||||
}
|
||||
|
||||
const MAX_MB = 5;
|
||||
const MAX_IMG_MB = 25; // original máximo aceptado antes de reducir
|
||||
const MAX_LADO = 1600; // lado mayor de la foto ya reducida
|
||||
const CALIDAD_JPG = 0.82;
|
||||
|
||||
/** Reduce una imagen en el navegador con canvas (sin librerías) para que la
|
||||
* subida sea ligera desde el celular. Devuelve un data URL JPEG. */
|
||||
const comprimirImagen = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
try {
|
||||
const escala = Math.min(1, MAX_LADO / Math.max(img.width, img.height));
|
||||
const w = Math.max(1, Math.round(img.width * escala));
|
||||
const h = Math.max(1, Math.round(img.height * escala));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Sin canvas');
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
resolve(canvas.toDataURL('image/jpeg', CALIDAD_JPG));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Imagen no válida')); };
|
||||
img.src = url;
|
||||
});
|
||||
|
||||
const leerUno = (file: File, esImagen: boolean): Promise<FotoLocal | null> => {
|
||||
if (esImagen) {
|
||||
if (file.size > MAX_IMG_MB * 1024 * 1024) return Promise.resolve(null);
|
||||
return comprimirImagen(file)
|
||||
.then((data_url) => ({ data_url, name: file.name }))
|
||||
.catch(() => (file.size <= MAX_MB * 1024 * 1024
|
||||
? new Promise<FotoLocal>((res) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => res({ data_url: String(r.result || ''), name: file.name });
|
||||
r.onerror = () => res(null as unknown as FotoLocal);
|
||||
r.readAsDataURL(file);
|
||||
})
|
||||
: Promise.resolve(null)));
|
||||
}
|
||||
if (file.size > MAX_MB * 1024 * 1024) return Promise.resolve(null);
|
||||
return new Promise((res) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => res({ data_url: String(reader.result || ''), name: file.name });
|
||||
reader.onerror = () => res(null);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const leerArchivos = (files: File[], esImagen: boolean, cb: (items: FotoLocal[]) => void) => {
|
||||
if (!files.length) return;
|
||||
Promise.all(files.map((f) => leerUno(f, esImagen)))
|
||||
.then((items) => cb(items.filter((i): i is FotoLocal => !!i && !!i.data_url)));
|
||||
};
|
||||
|
||||
const CampoFormulario: FC<Props> = ({
|
||||
campo, valor, onChange, error, disabled = false,
|
||||
firma, onFirma, fotos = [], onFotos, onQuitarFoto, textoLegal,
|
||||
}) => {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const esFoto = campo.tipo === 'foto';
|
||||
const acepta = esFoto ? 'image/*' : undefined;
|
||||
|
||||
const etiqueta = (
|
||||
<span className="flex items-center gap-1 text-sm font-medium text-theme-heading">
|
||||
{campo.etiqueta}
|
||||
{campo.obligatorio && <span className="text-rose-500">*</span>}
|
||||
</span>
|
||||
);
|
||||
|
||||
const ayuda = campo.ayuda ? <span className="mt-0.5 block text-xs text-theme-muted">{campo.ayuda}</span> : null;
|
||||
const errorEl = error ? <span className="mt-1 block text-xs font-medium text-rose-600">{error}</span> : null;
|
||||
|
||||
const claseInput =
|
||||
'w-full rounded-xl border border-theme-border bg-theme-surface px-3 py-3 text-base text-theme-heading ' +
|
||||
'focus:border-theme-border-strong focus:outline-none disabled:opacity-60';
|
||||
|
||||
const cuerpo = () => {
|
||||
switch (campo.tipo) {
|
||||
case 'texto_largo':
|
||||
return (
|
||||
<textarea
|
||||
id={campo.clave}
|
||||
name={campo.clave}
|
||||
className={claseInput}
|
||||
rows={4}
|
||||
value={String(valor ?? '')}
|
||||
disabled={disabled}
|
||||
placeholder={campo.ayuda || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
case 'numero':
|
||||
return (
|
||||
<input
|
||||
id={campo.clave}
|
||||
name={campo.clave}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
className={claseInput}
|
||||
value={valor === undefined || valor === null ? '' : String(valor)}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
/>
|
||||
);
|
||||
case 'fecha':
|
||||
return (
|
||||
<input
|
||||
id={campo.clave}
|
||||
name={campo.clave}
|
||||
type="date"
|
||||
className={claseInput}
|
||||
value={String(valor ?? '')}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
case 'seleccion':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{(campo.opciones || []).map((op, idx) => (
|
||||
<label
|
||||
key={op}
|
||||
className={`flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-3 text-sm ${
|
||||
valor === op ? 'border-theme-border-strong bg-theme-accent-bg' : 'border-theme-border bg-theme-surface'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
id={`${campo.clave}-${idx}`}
|
||||
type="radio"
|
||||
name={campo.clave}
|
||||
value={op}
|
||||
checked={valor === op}
|
||||
disabled={disabled}
|
||||
onChange={() => onChange(op)}
|
||||
className="h-4 w-4 accent-theme-accent"
|
||||
/>
|
||||
<span className="text-theme-heading">{op}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case 'multi_seleccion': {
|
||||
const seleccion = Array.isArray(valor) ? (valor as string[]) : [];
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{(campo.opciones || []).map((op) => {
|
||||
const activo = seleccion.includes(op);
|
||||
return (
|
||||
<label
|
||||
key={op}
|
||||
className={`flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-3 text-sm ${
|
||||
activo ? 'border-theme-border-strong bg-theme-accent-bg' : 'border-theme-border bg-theme-surface'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
id={`${campo.clave}-${op}`}
|
||||
name={campo.clave}
|
||||
type="checkbox"
|
||||
checked={activo}
|
||||
disabled={disabled}
|
||||
onChange={() => onChange(activo ? seleccion.filter((s) => s !== op) : [...seleccion, op])}
|
||||
className="h-4 w-4 accent-theme-accent"
|
||||
/>
|
||||
<span className="text-theme-heading">{op}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'checkbox':
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center gap-3 rounded-xl border border-theme-border bg-theme-surface px-3 py-3 text-sm">
|
||||
<input
|
||||
id={campo.clave}
|
||||
name={campo.clave}
|
||||
type="checkbox"
|
||||
checked={Boolean(valor)}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="h-5 w-5 accent-theme-accent"
|
||||
/>
|
||||
<span className="text-theme-heading">{campo.etiqueta}</span>
|
||||
</label>
|
||||
);
|
||||
case 'escala': {
|
||||
const n = Number(valor);
|
||||
return (
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{Array.from({ length: 10 }, (_, i) => i + 1).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
data-value={v}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(v)}
|
||||
className={`rounded-xl border py-3 text-base font-medium ${
|
||||
n === v ? 'border-theme-border-strong bg-theme-accent text-theme-inverse' : 'border-theme-border bg-theme-surface text-theme-heading'
|
||||
}`}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'firma':
|
||||
return (
|
||||
<FirmaCanvas value={firma ?? null} onChange={(v) => onFirma?.(v)} disabled={disabled} />
|
||||
);
|
||||
case 'foto':
|
||||
case 'archivo':
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept={acepta}
|
||||
multiple={esFoto}
|
||||
// En celular abre la cámara trasera para las fotos.
|
||||
{...(esFoto ? { capture: 'environment' as const } : {})}
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
e.target.value = '';
|
||||
if (files.length) leerArchivos(files, esFoto, (items) => onFotos?.(items));
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-theme-border-strong bg-theme-surface px-3 py-3 text-sm font-medium text-theme-heading"
|
||||
>
|
||||
{esFoto ? <Camera size={16} /> : <Paperclip size={16} />}
|
||||
{esFoto ? 'Tomar foto o elegir de la galería' : 'Elegir archivo'}
|
||||
</button>
|
||||
{fotos.length > 0 && (
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
{fotos.map((f, i) => (
|
||||
<div key={`${f.name}-${i}`} className="relative">
|
||||
{esFoto && f.data_url ? (
|
||||
<img src={f.data_url} alt={f.name} className="h-20 w-full rounded-xl border border-theme-border object-cover" />
|
||||
) : (
|
||||
<div className="flex h-20 items-center justify-center rounded-xl border border-theme-border bg-theme-bg px-2 text-center text-[11px] text-theme-muted">
|
||||
<FileText size={14} className="mr-1 shrink-0" /> <span className="truncate">{f.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onQuitarFoto?.(i)}
|
||||
className="absolute right-1 top-1 rounded-full bg-theme-surface/90 p-1 text-theme-muted hover:text-rose-600"
|
||||
title="Quitar"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'consentimiento':
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-theme-border bg-theme-surface px-3 py-3 text-sm">
|
||||
<input
|
||||
id={campo.clave}
|
||||
name={campo.clave}
|
||||
type="checkbox"
|
||||
checked={Boolean(valor)}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="mt-0.5 h-5 w-5 shrink-0 accent-theme-accent"
|
||||
/>
|
||||
<span className="text-theme-heading">{campo.etiqueta}</span>
|
||||
</label>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<input
|
||||
id={campo.clave}
|
||||
name={campo.clave}
|
||||
type="text"
|
||||
className={claseInput}
|
||||
value={String(valor ?? '')}
|
||||
disabled={disabled}
|
||||
placeholder={campo.ayuda || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-3" {...(error ? { 'data-error': '1' } : {})}>
|
||||
{campo.tipo !== 'checkbox' && campo.tipo !== 'consentimiento' && (
|
||||
<div className="mb-2">
|
||||
{etiqueta}
|
||||
{ayuda}
|
||||
</div>
|
||||
)}
|
||||
{campo.tipo === 'consentimiento' && textoLegal && (
|
||||
<div className="mb-2 max-h-56 overflow-y-auto rounded-xl bg-theme-bg p-3 text-xs leading-relaxed text-theme-muted whitespace-pre-wrap">
|
||||
{textoLegal}
|
||||
</div>
|
||||
)}
|
||||
{cuerpo()}
|
||||
{campo.tipo === 'consentimiento' && ayuda}
|
||||
{errorEl}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CampoFormulario;
|
||||
170
frontend/src/components/formulario/EnviarFormularioModal.tsx
Normal file
170
frontend/src/components/formulario/EnviarFormularioModal.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Copy, ExternalLink, Send } from 'lucide-react';
|
||||
import { Button, Modal, Select, toast } from '../ui';
|
||||
import { PatientCombobox } from '../PatientCombobox';
|
||||
import { odooApi, type FormularioCanal, type FormularioPlantilla } from '../../services/odoo';
|
||||
import { urlBasePublica } from '../../lib/publicBooking';
|
||||
import { useClinicSettings } from '../../lib/clinic-settings';
|
||||
import { useGlosario } from '../../lib/glosario';
|
||||
|
||||
// Modal reutilizable para enviar un formulario a un paciente. Se abre desde el
|
||||
// expediente del paciente y desde el detalle de la cita. Canal 'enlace'
|
||||
// devuelve la liga para compartir; whatsapp/email la encolan por el motor de
|
||||
// notificaciones (si está apagado, la liga se devuelve igual).
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
patientId?: number | null;
|
||||
patientName?: string;
|
||||
citaId?: number | null;
|
||||
onSent?: () => void;
|
||||
}
|
||||
|
||||
const EnviarFormularioModal: FC<Props> = ({ isOpen, onClose, patientId = null, patientName = '', citaId = null, onSent }) => {
|
||||
const settings = useClinicSettings();
|
||||
const { term } = useGlosario();
|
||||
const [plantillas, setPlantillas] = useState<FormularioPlantilla[]>([]);
|
||||
const [plantillaId, setPlantillaId] = useState('');
|
||||
const [paciente, setPaciente] = useState<string>(patientId ? String(patientId) : '');
|
||||
const [canal, setCanal] = useState<FormularioCanal>('enlace');
|
||||
const [enviando, setEnviando] = useState(false);
|
||||
const [enlace, setEnlace] = useState<string | null>(null);
|
||||
const [notificado, setNotificado] = useState(false);
|
||||
const [copiado, setCopiado] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setEnlace(null);
|
||||
setCopiado(false);
|
||||
if (patientId) setPaciente(String(patientId));
|
||||
odooApi.getFormularioPlantillas()
|
||||
.then((res) => {
|
||||
if (res.status === 'success') {
|
||||
setPlantillas(res.plantillas);
|
||||
setPlantillaId((prev) => prev || (res.plantillas[0] ? String(res.plantillas[0].id) : ''));
|
||||
}
|
||||
})
|
||||
.catch(() => toast.error('No se pudieron cargar las plantillas'));
|
||||
}, [isOpen, patientId]);
|
||||
|
||||
const enlaceCompleto = enlace ? (enlace.startsWith('http') ? enlace : `${urlBasePublica(settings.url_publica)}${enlace}`) : '';
|
||||
|
||||
const enviar = async () => {
|
||||
if (!plantillaId) { toast.error('Elige una plantilla'); return; }
|
||||
if (!paciente) { toast.error(`Elige un ${term('patient_singular').toLowerCase()}`); return; }
|
||||
setEnviando(true);
|
||||
try {
|
||||
const res = await odooApi.enviarFormulario({
|
||||
plantilla_id: Number(plantillaId),
|
||||
partner_id: Number(paciente),
|
||||
cita_id: citaId || null,
|
||||
canal,
|
||||
});
|
||||
setEnlace(res.enlace);
|
||||
setNotificado(!!res.notificado);
|
||||
onSent?.();
|
||||
toast.success('Formulario enviado');
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo enviar el formulario');
|
||||
} finally {
|
||||
setEnviando(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copiar = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(enlaceCompleto);
|
||||
setCopiado(true);
|
||||
setTimeout(() => setCopiado(false), 2000);
|
||||
} catch {
|
||||
toast.error('No se pudo copiar el enlace');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Enviar formulario"
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
enlace ? (
|
||||
<Button variant="outline" onClick={onClose}>Cerrar</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose}>Cancelar</Button>
|
||||
<Button loading={enviando} onClick={enviar}>
|
||||
<Send size={16} className="mr-2" /> Enviar
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{plantillas.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">
|
||||
Aún no hay plantillas de formulario. Crea una en Configuración › Formularios.
|
||||
</p>
|
||||
) : enlace ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-theme-heading">
|
||||
El formulario quedó pendiente. Comparte esta liga con {patientName || `el ${term('patient_singular').toLowerCase()}`}:
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-theme-border bg-theme-bg p-3">
|
||||
<input
|
||||
readOnly
|
||||
value={enlaceCompleto}
|
||||
className="min-w-0 flex-1 rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-xs text-theme-heading"
|
||||
/>
|
||||
<Button variant="ghost" onClick={copiar}>
|
||||
<Copy size={14} className="mr-1" /> {copiado ? 'Copiado' : 'Copiar'}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => window.open(enlaceCompleto, '_blank', 'noopener')}>
|
||||
<ExternalLink size={14} className="mr-1" /> Abrir
|
||||
</Button>
|
||||
</div>
|
||||
{canal !== 'enlace' && (
|
||||
<p className="text-xs text-theme-muted">
|
||||
{notificado
|
||||
? `Se encoló el aviso por ${canal === 'whatsapp' ? 'WhatsApp' : 'correo'}.`
|
||||
: 'No se envió el aviso automático (notificaciones apagadas, modo prueba o sin dato de contacto). Comparte la liga manualmente.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Select
|
||||
label="Plantilla"
|
||||
value={plantillaId}
|
||||
onChange={(e) => setPlantillaId(e.target.value)}
|
||||
options={plantillas.map((p) => ({ value: String(p.id), label: p.name }))}
|
||||
/>
|
||||
{patientId ? (
|
||||
<div>
|
||||
<p className="mb-1 text-sm font-medium text-theme-heading">{term('patient_singular')}</p>
|
||||
<p className="rounded-xl border border-theme-border bg-theme-bg px-3 py-2.5 text-sm text-theme-heading">
|
||||
{patientName || `#${patientId}`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<PatientCombobox value={paciente} onChange={(id) => setPaciente(id)} />
|
||||
)}
|
||||
<Select
|
||||
label="Canal"
|
||||
value={canal}
|
||||
onChange={(e) => setCanal(e.target.value as FormularioCanal)}
|
||||
options={[
|
||||
{ value: 'enlace', label: 'Enlace (compartir manualmente)' },
|
||||
{ value: 'whatsapp', label: 'WhatsApp' },
|
||||
{ value: 'email', label: 'Correo' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnviarFormularioModal;
|
||||
124
frontend/src/components/formulario/FirmaCanvas.tsx
Normal file
124
frontend/src/components/formulario/FirmaCanvas.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Eraser, PenLine } from 'lucide-react';
|
||||
|
||||
// Firma en canvas con el dedo (o el mouse). Sin librerías: pointer events y
|
||||
// un trazo de grosor generoso, pensado para una mano y pantalla de celular.
|
||||
// El valor que sube al padre es un data URL PNG (o null si se limpia).
|
||||
|
||||
interface Props {
|
||||
value: string | null;
|
||||
onChange: (dataUrl: string | null) => void;
|
||||
disabled?: boolean;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const FirmaCanvas: FC<Props> = ({ value, onChange, disabled = false, height = 200 }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const dibujando = useRef(false);
|
||||
const hayTrazo = useRef(false);
|
||||
const [vacio, setVacio] = useState(!value);
|
||||
|
||||
const preparar = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
const ancho = canvas.clientWidth || 320;
|
||||
canvas.width = Math.round(ancho * ratio);
|
||||
canvas.height = Math.round(height * ratio);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, ancho, height);
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = '#111111';
|
||||
return ctx;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
preparar();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [height]);
|
||||
|
||||
const posicion = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
};
|
||||
|
||||
const empezar = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (disabled) return;
|
||||
const ctx = canvasRef.current?.getContext('2d');
|
||||
if (!ctx) return;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
dibujando.current = true;
|
||||
const { x, y } = posicion(e);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
};
|
||||
|
||||
const mover = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!dibujando.current || disabled) return;
|
||||
const ctx = canvasRef.current?.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const { x, y } = posicion(e);
|
||||
ctx.lineTo(x, y);
|
||||
ctx.stroke();
|
||||
hayTrazo.current = true;
|
||||
if (vacio) setVacio(false);
|
||||
};
|
||||
|
||||
const terminar = () => {
|
||||
if (!dibujando.current) return;
|
||||
dibujando.current = false;
|
||||
if (hayTrazo.current && canvasRef.current) {
|
||||
onChange(canvasRef.current.toDataURL('image/png'));
|
||||
setVacio(false);
|
||||
}
|
||||
};
|
||||
|
||||
const limpiar = () => {
|
||||
preparar();
|
||||
hayTrazo.current = false;
|
||||
setVacio(true);
|
||||
onChange(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative rounded-2xl border-2 border-dashed border-theme-border-strong bg-white overflow-hidden">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={empezar}
|
||||
onPointerMove={mover}
|
||||
onPointerUp={terminar}
|
||||
onPointerLeave={terminar}
|
||||
onPointerCancel={terminar}
|
||||
style={{ touchAction: 'none', height, width: '100%', display: 'block' }}
|
||||
/>
|
||||
{vacio && !disabled && (
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-theme-muted">
|
||||
<PenLine size={22} />
|
||||
<span className="mt-1 text-xs">Firma aquí con el dedo</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-xs text-theme-muted">Toca y arrastra para firmar</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={limpiar}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-theme-accent-bg px-3 py-1 text-xs font-medium text-theme-heading hover:bg-theme-bg"
|
||||
>
|
||||
<Eraser size={13} /> Limpiar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FirmaCanvas;
|
||||
140
frontend/src/components/formulario/FormularioCampos.tsx
Normal file
140
frontend/src/components/formulario/FormularioCampos.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { FC } from 'react';
|
||||
import { Camera, ShieldCheck } from 'lucide-react';
|
||||
import type { FormularioCampo, FormularioMomento } from '../../services/odoo';
|
||||
import CampoFormulario, { type FotoLocal } from './CampoFormulario';
|
||||
|
||||
// Contenedor compartido por el portal público y la vista previa del constructor:
|
||||
// pinta los campos en orden, el texto legal, la firma y las fotos. El estado
|
||||
// (valores, firma, fotos) lo guarda quien lo usa.
|
||||
|
||||
export interface FotoConCampo extends FotoLocal {
|
||||
campo_clave: string;
|
||||
kind?: FormularioMomento;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
campos: FormularioCampo[];
|
||||
valores: Record<string, unknown>;
|
||||
onChange: (clave: string, v: unknown) => void;
|
||||
firma?: string | null;
|
||||
onFirma?: (v: string | null) => void;
|
||||
firmaNombre?: string;
|
||||
onFirmaNombre?: (v: string) => void;
|
||||
fotos?: FotoConCampo[];
|
||||
setFotos?: (f: FotoConCampo[]) => void;
|
||||
errores?: Record<string, string>;
|
||||
disabled?: boolean;
|
||||
textoLegal?: string;
|
||||
requiereFirma?: boolean;
|
||||
requiereFoto?: boolean;
|
||||
}
|
||||
|
||||
const FormularioCampos: FC<Props> = ({
|
||||
campos, valores, onChange, firma = null, onFirma, firmaNombre = '', onFirmaNombre,
|
||||
fotos = [], setFotos, errores = {}, disabled = false, textoLegal = '',
|
||||
requiereFirma = false, requiereFoto = false,
|
||||
}) => {
|
||||
const tieneConsentimiento = campos.some((c) => c.tipo === 'consentimiento');
|
||||
const tieneFirmaCampo = campos.some((c) => c.tipo === 'firma');
|
||||
const tieneFotoCampo = campos.some((c) => c.tipo === 'foto');
|
||||
const pideFirma = requiereFirma || tieneFirmaCampo;
|
||||
const fotosGenerales = fotos.filter((f) => f.campo_clave === '__general');
|
||||
|
||||
const agregar = (clave: string, items: FotoLocal[]) => {
|
||||
if (!setFotos) return;
|
||||
const validos = items.filter((i) => i.data_url);
|
||||
setFotos([...fotos, ...validos.map((i) => ({ campo_clave: clave, ...i }))]);
|
||||
};
|
||||
|
||||
const quitar = (clave: string, idx: number) => {
|
||||
if (!setFotos) return;
|
||||
const propias = fotos.filter((f) => f.campo_clave === clave);
|
||||
const objetivo = propias[idx];
|
||||
setFotos(fotos.filter((f) => f !== objetivo));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-theme-border">
|
||||
{textoLegal && !tieneConsentimiento && (
|
||||
<div className="py-3">
|
||||
<div className="max-h-72 overflow-y-auto rounded-2xl bg-theme-bg p-4 text-sm leading-relaxed text-theme-muted whitespace-pre-wrap">
|
||||
{textoLegal}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{campos.map((campo) => (
|
||||
<CampoFormulario
|
||||
key={campo.clave}
|
||||
campo={campo}
|
||||
valor={valores[campo.clave] ?? ''}
|
||||
onChange={(v) => onChange(campo.clave, v)}
|
||||
error={errores[campo.clave]}
|
||||
disabled={disabled}
|
||||
firma={firma}
|
||||
onFirma={onFirma}
|
||||
fotos={fotos.filter((f) => f.campo_clave === campo.clave)}
|
||||
onFotos={(items) => agregar(campo.clave, items)}
|
||||
onQuitarFoto={(i) => quitar(campo.clave, i)}
|
||||
textoLegal={textoLegal}
|
||||
/>
|
||||
))}
|
||||
|
||||
{requiereFoto && !tieneFotoCampo && (
|
||||
<div className="py-3">
|
||||
<CampoFormulario
|
||||
campo={{ clave: '__general', etiqueta: 'Fotos', tipo: 'foto', obligatorio: true, ayuda: '', opciones: [], momento: 'otro' }}
|
||||
valor={fotosGenerales.length > 0}
|
||||
onChange={() => {}}
|
||||
error={errores.__fotos}
|
||||
disabled={disabled}
|
||||
fotos={fotosGenerales}
|
||||
onFotos={(items) => agregar('__general', items)}
|
||||
onQuitarFoto={(i) => quitar('__general', i)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pideFirma && !tieneFirmaCampo && (
|
||||
<div className="py-3">
|
||||
<span className="mb-2 flex items-center gap-1 text-sm font-medium text-theme-heading">
|
||||
<ShieldCheck size={15} /> Firma <span className="text-rose-500">*</span>
|
||||
</span>
|
||||
<CampoFormulario
|
||||
campo={{ clave: 'firma', etiqueta: 'Firma', tipo: 'firma', obligatorio: true, ayuda: '', opciones: [] }}
|
||||
valor={Boolean(firma)}
|
||||
onChange={() => {}}
|
||||
error={errores.firma || errores.__firma}
|
||||
disabled={disabled}
|
||||
firma={firma}
|
||||
onFirma={onFirma}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pideFirma && (
|
||||
<div className="py-3">
|
||||
<label className="mb-2 block text-sm font-medium text-theme-heading">
|
||||
Firmado por <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="firma_nombre"
|
||||
type="text"
|
||||
value={firmaNombre}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onFirmaNombre?.(e.target.value)}
|
||||
placeholder="Nombre completo de quien firma"
|
||||
className="w-full rounded-xl border border-theme-border bg-theme-surface px-3 py-3 text-base text-theme-heading focus:border-theme-border-strong focus:outline-none disabled:opacity-60"
|
||||
/>
|
||||
{!tieneFotoCampo && (
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-theme-muted">
|
||||
<Camera size={12} /> Puedes adjuntar fotos en los campos de arriba.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormularioCampos;
|
||||
122
frontend/src/components/formulario/tipos.ts
Normal file
122
frontend/src/components/formulario/tipos.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { FormularioCampo, FormularioCampoTipo, FormularioMomento } from '../../services/odoo';
|
||||
|
||||
// Catálogo y utilidades compartidas por el constructor, la vista previa y el
|
||||
// portal público de formularios. Sin dependencias: un espejo del backend.
|
||||
|
||||
export const CAMPO_TIPOS: { tipo: FormularioCampoTipo; label: string; descripcion: string }[] = [
|
||||
{ tipo: 'texto', label: 'Texto corto', descripcion: 'Una línea (nombre, teléfono…)' },
|
||||
{ tipo: 'texto_largo', label: 'Texto largo', descripcion: 'Varias líneas' },
|
||||
{ tipo: 'numero', label: 'Número', descripcion: 'Cantidad o peso' },
|
||||
{ tipo: 'fecha', label: 'Fecha', descripcion: 'Selector de fecha' },
|
||||
{ tipo: 'seleccion', label: 'Una opción', descripcion: 'Elegir una de varias' },
|
||||
{ tipo: 'multi_seleccion', label: 'Varias opciones', descripcion: 'Elegir varias' },
|
||||
{ tipo: 'checkbox', label: 'Casilla', descripcion: 'Sí / no' },
|
||||
{ tipo: 'escala', label: 'Escala 1-10', descripcion: 'Nivel o intensidad' },
|
||||
{ tipo: 'firma', label: 'Firma', descripcion: 'Firma con el dedo' },
|
||||
{ tipo: 'foto', label: 'Foto', descripcion: 'Cámara o galería' },
|
||||
{ tipo: 'archivo', label: 'Archivo', descripcion: 'Documento o estudio' },
|
||||
{ tipo: 'consentimiento', label: 'Consentimiento', descripcion: 'Aceptar el texto legal' },
|
||||
];
|
||||
|
||||
export const TIPO_LABEL: Record<FormularioCampoTipo, string> =
|
||||
Object.fromEntries(CAMPO_TIPOS.map((t) => [t.tipo, t.label])) as Record<FormularioCampoTipo, string>;
|
||||
|
||||
export const PLANTILLA_TIPOS: { value: string; label: string }[] = [
|
||||
{ value: 'consentimiento', label: 'Consentimiento informado' },
|
||||
{ value: 'expediente', label: 'Nota de expediente' },
|
||||
{ value: 'encuesta', label: 'Encuesta' },
|
||||
{ value: 'autorizacion', label: 'Autorización' },
|
||||
];
|
||||
|
||||
export const MOMENTO_LABEL: Record<FormularioMomento, string> = {
|
||||
antes: 'Antes',
|
||||
despues: 'Después',
|
||||
otro: 'Otro',
|
||||
};
|
||||
|
||||
export const ESTADO_LABEL: Record<string, string> = {
|
||||
borrador: 'Borrador',
|
||||
enviado: 'Pendiente',
|
||||
llenado: 'Lleno',
|
||||
cancelado: 'Cancelado',
|
||||
};
|
||||
|
||||
/** True si el formulario necesita firma (bandera de la plantilla o campo firma). */
|
||||
export const necesitaFirma = (campos: FormularioCampo[], requiereFirma?: boolean): boolean =>
|
||||
Boolean(requiereFirma) || campos.some((c) => c.tipo === 'firma');
|
||||
|
||||
/** True si necesita al menos una foto (bandera de la plantilla). */
|
||||
export const necesitaFoto = (campos: FormularioCampo[], requiereFoto?: boolean): boolean =>
|
||||
Boolean(requiereFoto) || campos.some((c) => c.tipo === 'foto' && c.obligatorio);
|
||||
|
||||
export const valorInicial = (campo: FormularioCampo): unknown => {
|
||||
switch (campo.tipo) {
|
||||
case 'multi_seleccion': return [];
|
||||
case 'checkbox':
|
||||
case 'consentimiento':
|
||||
case 'firma':
|
||||
case 'foto':
|
||||
case 'archivo': return false;
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
/** Interruptores que la plantilla exige sí o sí (se muestran sin poder apagarse). */
|
||||
export const firmaObligatoria = (campos: FormularioCampo[]): boolean =>
|
||||
campos.some((c) => c.tipo === 'firma' && c.obligatorio);
|
||||
|
||||
/** Validación en el cliente (el backend vuelve a validar). Devuelve errores por clave. */
|
||||
export function validarLocal(
|
||||
campos: FormularioCampo[],
|
||||
valores: Record<string, unknown>,
|
||||
firma: string | null,
|
||||
fotos: { campo_clave: string }[],
|
||||
requiereFirma: boolean,
|
||||
requiereFoto: boolean,
|
||||
): Record<string, string> {
|
||||
const errores: Record<string, string> = {};
|
||||
for (const campo of campos) {
|
||||
const v = valores[campo.clave];
|
||||
const vacio = v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
|
||||
switch (campo.tipo) {
|
||||
case 'consentimiento':
|
||||
if (campo.obligatorio && !v) errores[campo.clave] = 'Debes aceptar para continuar';
|
||||
break;
|
||||
case 'firma':
|
||||
if (campo.obligatorio && !firma) errores[campo.clave] = 'Falta tu firma';
|
||||
break;
|
||||
case 'foto':
|
||||
case 'archivo':
|
||||
if (campo.obligatorio && !fotos.some((f) => f.campo_clave === campo.clave)) {
|
||||
errores[campo.clave] = campo.tipo === 'foto' ? 'Falta una foto' : 'Falta adjuntar el archivo';
|
||||
}
|
||||
break;
|
||||
case 'escala': {
|
||||
if (vacio) { if (campo.obligatorio) errores[campo.clave] = 'Elige un valor'; break; }
|
||||
const n = Number(v);
|
||||
if (!(n >= 1 && n <= 10)) errores[campo.clave] = 'Debe ser del 1 al 10';
|
||||
break;
|
||||
}
|
||||
case 'numero':
|
||||
if (vacio) { if (campo.obligatorio) errores[campo.clave] = 'Falta este dato'; break; }
|
||||
if (Number.isNaN(Number(v))) errores[campo.clave] = 'Escribe un número';
|
||||
break;
|
||||
case 'checkbox':
|
||||
break;
|
||||
default:
|
||||
if (campo.obligatorio && vacio) errores[campo.clave] = 'Falta responder';
|
||||
}
|
||||
}
|
||||
if (requiereFirma && !firma) errores.__firma = 'Este formulario requiere firma';
|
||||
if (requiereFoto && fotos.length === 0) errores.__fotos = 'Este formulario requiere al menos una foto';
|
||||
return errores;
|
||||
}
|
||||
|
||||
/** Texto legible del valor de un campo, para el detalle de la respuesta. */
|
||||
export function valorLegible(campo: FormularioCampo, valor: unknown): string {
|
||||
if (valor === undefined || valor === null || valor === '') return '—';
|
||||
if (Array.isArray(valor)) return valor.length ? valor.join(', ') : '—';
|
||||
if (typeof valor === 'boolean') return valor ? 'Sí' : 'No';
|
||||
if (campo.tipo === 'escala') return `${valor} / 10`;
|
||||
return String(valor);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export const PERM_KEYS: string[] = [
|
||||
'reportes.ver', 'reportes.exportar', 'caja.ver', 'caja.cerrar',
|
||||
'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar',
|
||||
'notificaciones.ver', 'notificaciones.enviar', 'notificaciones.editar',
|
||||
'formularios.ver', 'formularios.crear', 'formularios.editar', 'formularios.enviar',
|
||||
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
|
||||
];
|
||||
|
||||
@@ -26,6 +27,8 @@ const MONEY = W.filter((r) => r !== 'medico' && r !== 'marketing');
|
||||
const AG = ['admin', 'gerencia'];
|
||||
const AD = ['admin'];
|
||||
const ALL = [...W, 'lectura'];
|
||||
// Llenar/enviar formularios: quien atiende al paciente.
|
||||
const FORM_WRITE = ['admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'];
|
||||
|
||||
const READ_PERM_KEYS = new Set([
|
||||
'agenda.ver', 'agenda.todas',
|
||||
@@ -37,6 +40,7 @@ const READ_PERM_KEYS = new Set([
|
||||
'ventas.reimprimir',
|
||||
'whatsapp.ver',
|
||||
'notificaciones.ver',
|
||||
'formularios.ver',
|
||||
]);
|
||||
|
||||
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
||||
@@ -56,6 +60,8 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
||||
'reportes.ver': ALL, 'reportes.exportar': ALL, 'caja.ver': ALL, 'caja.cerrar': MONEY,
|
||||
'whatsapp.ver': W, 'whatsapp.responder': W, 'whatsapp.asignar': W,
|
||||
'notificaciones.ver': ALL, 'notificaciones.enviar': W, 'notificaciones.editar': AG,
|
||||
'formularios.ver': ALL, 'formularios.crear': FORM_WRITE, 'formularios.editar': AG,
|
||||
'formularios.enviar': FORM_WRITE,
|
||||
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall, Wallet, ChevronDown, CalendarOff, Users, Play, Check, Globe, Bell } from 'lucide-react';
|
||||
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall, Wallet, ChevronDown, CalendarOff, Users, Play, Check, Globe, Bell, FileSignature } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||
import AgendaPrint from '../components/AgendaPrint';
|
||||
import PatientCombobox from '../components/PatientCombobox';
|
||||
import RecargarSaldoModal from '../components/RecargarSaldoModal';
|
||||
import ReciboPrint from '../components/ReciboPrint';
|
||||
import EnviarFormularioModal from '../components/formulario/EnviarFormularioModal';
|
||||
import BloqueosModal, { quickCloseOptions, type BloqueoQuickClose } from '../components/agenda/BloqueosModal';
|
||||
import { ListaEsperaPanel, ListaEsperaModal, whatsappLink } from '../components/agenda/ListaEspera';
|
||||
import { EstadoChecklist, CHECKLIST_STEPS, stepIndex } from '../components/agenda/EstadoChecklist';
|
||||
@@ -234,6 +235,7 @@ const Agenda: FC = () => {
|
||||
// sin depender de la búsqueda ni de la lista de citas cargadas).
|
||||
const [pickedPatient, setPickedPatient] = useState<{ id: string; name: string } | null>(null);
|
||||
const [detailApt, setDetailApt] = useState<Appointment | null>(null);
|
||||
const [enviarFormApt, setEnviarFormApt] = useState<Appointment | null>(null);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editForm, setEditForm] = useState({ date: '', time: '', doctor_id: '', notes: '' });
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
@@ -3051,6 +3053,10 @@ const Agenda: FC = () => {
|
||||
<Button variant="outline" onClick={() => setEditMode(false)}>Volver</Button>
|
||||
<Button onClick={saveAptEdit} loading={savingEdit}>Guardar cambios</Button>
|
||||
</>
|
||||
) : detailApt && can('formularios.enviar') ? (
|
||||
<Button variant="outline" onClick={() => setEnviarFormApt(detailApt)}>
|
||||
<FileSignature size={16} className="mr-2" /> Enviar formulario
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
@@ -3277,6 +3283,15 @@ const Agenda: FC = () => {
|
||||
/>
|
||||
<ReciboPrint receipt={reciboApt} onClose={() => setReciboApt(null)} />
|
||||
|
||||
{/* Envío de formulario (consentimiento/encuesta) al paciente de la cita. */}
|
||||
<EnviarFormularioModal
|
||||
isOpen={!!enviarFormApt}
|
||||
onClose={() => setEnviarFormApt(null)}
|
||||
patientId={enviarFormApt?.patient_id ?? null}
|
||||
patientName={enviarFormApt?.patient}
|
||||
citaId={enviarFormApt?.id ?? null}
|
||||
/>
|
||||
|
||||
{/* Alta de paciente en la lista de espera */}
|
||||
<ListaEsperaModal
|
||||
isOpen={listaModalOpen}
|
||||
|
||||
@@ -16,6 +16,7 @@ import TerminologiaPanel from '../components/TerminologiaPanel';
|
||||
import ReservasOnlinePanel from '../components/ReservasOnlinePanel';
|
||||
import NotificacionesPanel from '../components/NotificacionesPanel';
|
||||
import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPanel';
|
||||
import FormulariosPanel from '../components/FormulariosPanel';
|
||||
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
||||
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
@@ -26,6 +27,7 @@ const TABS = [
|
||||
{ key: 'reservas', label: 'Reservas en línea' },
|
||||
{ key: 'notificaciones', label: 'Notificaciones' },
|
||||
{ key: 'notif_enviadas', label: 'Notificaciones enviadas' },
|
||||
{ key: 'formularios', label: 'Formularios' },
|
||||
{ key: 'sucursales', label: 'Sucursales' },
|
||||
{ key: 'categorias', label: 'Categorías' },
|
||||
{ key: 'terminologia', label: 'Terminología' },
|
||||
@@ -38,7 +40,7 @@ const TABS = [
|
||||
] as const;
|
||||
|
||||
// Secciones que ve gerencia (admin del sistema ve todas).
|
||||
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'sucursales', 'categorias', 'terminologia', 'usuarios', 'roles', 'tickets'];
|
||||
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'terminologia', 'usuarios', 'roles', 'tickets'];
|
||||
|
||||
const SUBCATALOGOS = [
|
||||
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
||||
@@ -61,6 +63,7 @@ const Configuracion: FC = () => {
|
||||
auditoria: 'config.auditoria',
|
||||
notificaciones: 'notificaciones.editar',
|
||||
notif_enviadas: 'notificaciones.ver',
|
||||
formularios: 'formularios.ver',
|
||||
};
|
||||
const tabsVisibles = useMemo(
|
||||
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key)))
|
||||
@@ -400,6 +403,12 @@ const Configuracion: FC = () => {
|
||||
<NotificacionesEnviadasPanel />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'formularios' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<FormulariosPanel />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
<Card>
|
||||
|
||||
241
frontend/src/pages/FormularioPublico.tsx
Normal file
241
frontend/src/pages/FormularioPublico.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { AlertTriangle, CheckCircle2, Loader2, Send } from 'lucide-react';
|
||||
import { odooApi, type FormularioPublicoConfig } from '../services/odoo';
|
||||
import FormularioCampos, { type FotoConCampo } from '../components/formulario/FormularioCampos';
|
||||
import { necesitaFirma, necesitaFoto, validarLocal } from '../components/formulario/tipos';
|
||||
|
||||
// Portal público para que el paciente llene un formulario desde su celular,
|
||||
// con firma y fotos. Fuera del gate de autenticación: el token HMAC del enlace
|
||||
// es la única credencial. Respeta el brand y el glosario que manda el backend.
|
||||
|
||||
const FormularioPublico: FC = () => {
|
||||
const { token = '' } = useParams();
|
||||
const [config, setConfig] = useState<FormularioPublicoConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [noDisponible, setNoDisponible] = useState<string | null>(null);
|
||||
|
||||
const [valores, setValores] = useState<Record<string, unknown>>({});
|
||||
const [firma, setFirma] = useState<string | null>(null);
|
||||
const [firmaNombre, setFirmaNombre] = useState('');
|
||||
const [fotos, setFotos] = useState<FotoConCampo[]>([]);
|
||||
const [errores, setErrores] = useState<Record<string, string>>({});
|
||||
const [enviando, setEnviando] = useState(false);
|
||||
const [enviado, setEnviado] = useState(false);
|
||||
const [errorEnvio, setErrorEnvio] = useState<string | null>(null);
|
||||
const [yaLlenado, setYaLlenado] = useState(false);
|
||||
const [hp, setHp] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
if (!token) { setLoading(false); setNoDisponible('Enlace no válido'); return; }
|
||||
odooApi.getFormularioPublicoConfig(token)
|
||||
.then((res) => {
|
||||
if (!alive) return;
|
||||
if (res.status !== 'success') { setNoDisponible('Enlace no válido o expirado'); return; }
|
||||
setConfig(res);
|
||||
setYaLlenado(res.ya_llenado);
|
||||
setFirmaNombre((prev) => prev || res.paciente?.nombre || '');
|
||||
const inicial: Record<string, unknown> = {};
|
||||
for (const c of res.plantilla.campos) {
|
||||
if (c.tipo === 'multi_seleccion') inicial[c.clave] = [];
|
||||
}
|
||||
setValores(inicial);
|
||||
})
|
||||
.catch((err) => {
|
||||
const r = err as { response?: { status?: number } };
|
||||
if (alive) setNoDisponible(r?.response?.status === 409 ? 'Ya no disponible' : 'Enlace no válido o expirado');
|
||||
})
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [token]);
|
||||
|
||||
const campos = config?.plantilla.campos || [];
|
||||
const pideFirma = necesitaFirma(campos, config?.plantilla.requiere_firma);
|
||||
const pideFoto = necesitaFoto(campos, config?.plantilla.requiere_foto);
|
||||
const term = (k: string, fallback: string) =>
|
||||
(config?.terms as unknown as Record<string, string>)?.[k] || fallback;
|
||||
|
||||
const enviarLlenado = async () => {
|
||||
if (!config) return;
|
||||
const errs = validarLocal(campos, valores, firma, fotos, config.plantilla.requiere_firma, config.plantilla.requiere_foto);
|
||||
if (pideFirma && !firmaNombre.trim()) errs.firma_nombre = 'Escribe el nombre de quien firma';
|
||||
setErrores(errs);
|
||||
if (Object.keys(errs).length) {
|
||||
const primero = document.querySelector('[data-error="1"]');
|
||||
if (primero) primero.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
else window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
setEnviando(true);
|
||||
setErrorEnvio(null);
|
||||
try {
|
||||
// Los campos firma/foto no viajan como respuestas: su contenido va aparte.
|
||||
const respuestas: Record<string, unknown> = {};
|
||||
for (const c of campos) {
|
||||
if (c.tipo === 'firma' || c.tipo === 'foto' || c.tipo === 'archivo') continue;
|
||||
respuestas[c.clave] = valores[c.clave];
|
||||
}
|
||||
const res = await odooApi.llenarFormularioPublico({
|
||||
token,
|
||||
respuestas,
|
||||
firma_imagen: firma || undefined,
|
||||
firma_nombre: firmaNombre.trim() || undefined,
|
||||
fotos: fotos.map((f) => ({
|
||||
campo_clave: f.campo_clave,
|
||||
kind: f.kind,
|
||||
name: f.name,
|
||||
data_url: f.data_url,
|
||||
})),
|
||||
hp,
|
||||
});
|
||||
if (res.status === 'success') setEnviado(true);
|
||||
else setErrorEnvio(res.message || 'No se pudo enviar. Intenta de nuevo.');
|
||||
} catch (err) {
|
||||
const r = err as { response?: { status?: number; data?: { message?: string } } };
|
||||
if (r?.response?.status === 409) { setYaLlenado(true); return; }
|
||||
setErrorEnvio(r?.response?.data?.message || 'No se pudo enviar. Revisa tu conexión e intenta de nuevo.');
|
||||
} finally {
|
||||
setEnviando(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Centro>
|
||||
<Loader2 className="h-6 w-6 animate-spin text-theme-muted" />
|
||||
</Centro>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config || noDisponible) {
|
||||
return (
|
||||
<Centro>
|
||||
<div className="w-full max-w-sm rounded-3xl border border-theme-border bg-theme-surface p-8 text-center shadow-soft">
|
||||
<AlertTriangle className="mx-auto mb-3 h-8 w-8 text-amber-500" />
|
||||
<h1 className="font-heading text-lg text-theme-heading">Formulario no disponible</h1>
|
||||
<p className="mt-2 text-sm text-theme-muted">
|
||||
El enlace no es válido o ya expiró. Pide uno nuevo a la clínica.
|
||||
</p>
|
||||
</div>
|
||||
</Centro>
|
||||
);
|
||||
}
|
||||
|
||||
if (enviado || yaLlenado) {
|
||||
return (
|
||||
<Centro>
|
||||
<div className="w-full max-w-sm rounded-3xl border border-theme-border bg-theme-surface p-8 text-center shadow-soft">
|
||||
<CheckCircle2 className="mx-auto mb-3 h-10 w-10 text-emerald-500" />
|
||||
<h1 className="font-heading text-lg text-theme-heading">
|
||||
¡Gracias{config.paciente?.nombre ? `, ${config.paciente.nombre}` : ''}!
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-theme-muted">
|
||||
{enviado ? 'Tu formulario quedó registrado.' : 'Este formulario ya había sido llenado.'}
|
||||
</p>
|
||||
<p className="mt-4 text-xs text-theme-muted">Ya puedes cerrar esta página.</p>
|
||||
</div>
|
||||
</Centro>
|
||||
);
|
||||
}
|
||||
|
||||
const brand = config.brand;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-theme-bg pb-28">
|
||||
<header className="sticky top-0 z-10 border-b border-theme-border bg-theme-surface/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-lg items-center gap-3 px-4 py-3">
|
||||
{(brand.logo_oscuro || brand.logo_claro) && (
|
||||
<img
|
||||
src={brand.logo_oscuro || brand.logo_claro}
|
||||
alt={brand.nombre}
|
||||
className="h-8 max-w-[120px] object-contain"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-theme-heading">{brand.nombre}</p>
|
||||
<p className="truncate text-xs text-theme-muted">{config.plantilla.tipo_label || 'Formulario'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-lg px-4 py-5">
|
||||
<h1 className="font-heading text-xl text-theme-heading">{config.plantilla.nombre}</h1>
|
||||
{config.plantilla.descripcion && (
|
||||
<p className="mt-1 text-sm text-theme-muted whitespace-pre-wrap">{config.plantilla.descripcion}</p>
|
||||
)}
|
||||
{config.cita && (
|
||||
<p className="mt-2 rounded-xl bg-theme-accent-bg px-3 py-2 text-xs text-theme-heading">
|
||||
{term('appointment_singular', 'Cita')}
|
||||
{config.cita.fecha ? ` · ${config.cita.fecha}` : ''}
|
||||
{config.cita.hora ? ` ${config.cita.hora}` : ''}
|
||||
{config.cita.servicio ? ` · ${config.cita.servicio}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{config.plantilla.duracion_estimada_min > 0 && (
|
||||
<p className="mt-1 text-xs text-theme-muted">Tarda unos {config.plantilla.duracion_estimada_min} minutos.</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<FormularioCampos
|
||||
campos={campos}
|
||||
valores={valores}
|
||||
onChange={(clave, v) => setValores((prev) => ({ ...prev, [clave]: v }))}
|
||||
firma={firma}
|
||||
onFirma={setFirma}
|
||||
firmaNombre={firmaNombre}
|
||||
onFirmaNombre={setFirmaNombre}
|
||||
fotos={fotos}
|
||||
setFotos={setFotos}
|
||||
errores={errores}
|
||||
textoLegal={config.plantilla.texto_legal}
|
||||
requiereFirma={config.plantilla.requiere_firma}
|
||||
requiereFoto={config.plantilla.requiere_foto}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Honeypot: oculto para personas, un bot lo rellena. */}
|
||||
<input
|
||||
type="text"
|
||||
value={hp}
|
||||
onChange={(e) => setHp(e.target.value)}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
className="absolute left-[-9999px] h-0 w-0 opacity-0"
|
||||
/>
|
||||
|
||||
{errorEnvio && (
|
||||
<p className="mt-4 rounded-xl bg-rose-50 px-3 py-2 text-sm text-rose-700">{errorEnvio}</p>
|
||||
)}
|
||||
{pideFoto && pideFirma && (
|
||||
<p className="mt-4 text-xs text-theme-muted">
|
||||
Al enviar aceptas que tus datos y tu firma se guarden en el{' '}
|
||||
{term('record_singular', 'Expediente').toLowerCase()} de la clínica.
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<div className="fixed inset-x-0 bottom-0 border-t border-theme-border bg-theme-surface/95 px-4 py-3 backdrop-blur">
|
||||
<div className="mx-auto max-w-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={enviarLlenado}
|
||||
disabled={enviando}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-theme-accent px-4 py-4 text-base font-semibold text-theme-inverse disabled:opacity-60"
|
||||
>
|
||||
{enviando ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
|
||||
{enviando ? 'Enviando…' : 'Enviar formulario'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Centro: FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<div className="flex min-h-screen items-center justify-center bg-theme-bg p-6">{children}</div>
|
||||
);
|
||||
|
||||
export default FormularioPublico;
|
||||
@@ -3,13 +3,15 @@ import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Search, Plus, Activity, ShoppingBag, User, Edit2, Phone, Mail, Calendar, Droplet, Heart,
|
||||
MapPin, Briefcase, Users, Baby, Stethoscope, FileText, AlertCircle, CheckCircle2, XCircle,
|
||||
Download, ChevronRight, AlertTriangle, ClipboardList, Camera, Wallet, X,
|
||||
Download, ChevronRight, AlertTriangle, ClipboardList, Camera, Wallet, X, FileSignature,
|
||||
} from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import PatientAdjuntos from '../components/PatientAdjuntos';
|
||||
import EstadoCuentaPrint from '../components/EstadoCuentaPrint';
|
||||
import RecargarSaldoModal from '../components/RecargarSaldoModal';
|
||||
import ReciboPrint from '../components/ReciboPrint';
|
||||
import FormulariosPaciente from '../components/FormulariosPaciente';
|
||||
import EnviarFormularioModal from '../components/formulario/EnviarFormularioModal';
|
||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||
import {
|
||||
Card,
|
||||
@@ -144,10 +146,12 @@ const Pacientes: FC = () => {
|
||||
const [recargaOpen, setRecargaOpen] = useState(false);
|
||||
const [reciboDeposito, setReciboDeposito] = useState<ReceiptData | null>(null);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [tab, setTab] = useState<'info' | 'clinical' | 'timeline' | 'docs'>('info');
|
||||
const [tab, setTab] = useState<'info' | 'clinical' | 'timeline' | 'docs' | 'formularios'>('info');
|
||||
const [entryDetail, setEntryDetail] = useState<
|
||||
{ kind: 'cita'; item: Appointment } | { kind: 'venta'; item: Sale } | { kind: 'visita'; item: Visita } | null
|
||||
>(null);
|
||||
// Envío de formulario desde el detalle de la cita del historial
|
||||
const [enviarFormularioCita, setEnviarFormularioCita] = useState<{ patientId: number; patientName: string; citaId: number } | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [onlyIncomplete, setOnlyIncomplete] = useState(false);
|
||||
const [onlyVip, setOnlyVip] = useState(false);
|
||||
@@ -1002,6 +1006,7 @@ const Pacientes: FC = () => {
|
||||
{ key: 'clinical', label: 'Historia clínica' },
|
||||
{ key: 'timeline', label: 'Historial' },
|
||||
{ key: 'docs', label: 'Documentos' },
|
||||
{ key: 'formularios', label: 'Formularios' },
|
||||
] as const).map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
@@ -1192,6 +1197,10 @@ const Pacientes: FC = () => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : tab === 'formularios' ? (
|
||||
selected ? (
|
||||
<FormulariosPaciente patientId={selected.id} patientName={selected.name} />
|
||||
) : null
|
||||
) : (
|
||||
timelineItems.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
@@ -1253,6 +1262,20 @@ const Pacientes: FC = () => {
|
||||
onClose={() => setEntryDetail(null)}
|
||||
title={entryDetail?.kind === 'cita' ? 'Detalle de la cita' : entryDetail?.kind === 'visita' ? 'Detalle de la visita' : 'Detalle de la venta'}
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
entryDetail?.kind === 'cita' && can('formularios.enviar') ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEnviarFormularioCita({
|
||||
patientId: entryDetail.item.patient_id,
|
||||
patientName: entryDetail.item.patient,
|
||||
citaId: entryDetail.item.id,
|
||||
})}
|
||||
>
|
||||
<FileSignature size={16} className="mr-2" /> Enviar formulario
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{entryDetail?.kind === 'cita' && (
|
||||
<div className="space-y-4">
|
||||
@@ -1399,6 +1422,16 @@ const Pacientes: FC = () => {
|
||||
}}
|
||||
/>
|
||||
<ReciboPrint receipt={reciboDeposito} onClose={() => setReciboDeposito(null)} />
|
||||
<EnviarFormularioModal
|
||||
isOpen={!!enviarFormularioCita}
|
||||
onClose={() => setEnviarFormularioCita(null)}
|
||||
patientId={enviarFormularioCita?.patientId ?? null}
|
||||
patientName={enviarFormularioCita?.patientName}
|
||||
citaId={enviarFormularioCita?.citaId ?? null}
|
||||
onSent={() => {
|
||||
if (selected?.id) setTab('formularios');
|
||||
}}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1071,6 +1071,8 @@ export interface NotificacionesSettings {
|
||||
plantilla_confirmacion: string;
|
||||
plantilla_cumpleanos: string;
|
||||
plantilla_post_visita: string;
|
||||
/** Aviso de un formulario digital: usa {formulario} y {enlace}. */
|
||||
plantilla_formulario: string;
|
||||
whatsapp_numero: string;
|
||||
email_remitente: string;
|
||||
}
|
||||
@@ -1089,12 +1091,13 @@ export const NOTIFICACIONES_DEFAULT: NotificacionesSettings = {
|
||||
plantilla_confirmacion: 'Hola {nombre}, tu {cita} de {servicio} quedó registrada para el {fecha} a las {hora} con {doctor} en {sucursal}. Te esperamos.',
|
||||
plantilla_cumpleanos: '¡Feliz cumpleaños, {nombre}! Todo el equipo de {sucursal} te desea un gran día. Te esperamos pronto.',
|
||||
plantilla_post_visita: 'Hola {nombre}, gracias por tu visita de {servicio} el {fecha}. Si tienes dudas o quieres agendar de nuevo, llámanos al {telefono}.',
|
||||
plantilla_formulario: 'Hola {nombre}, te compartimos el formulario "{formulario}" para completar. Ábrelo desde tu celular aquí: {enlace}',
|
||||
whatsapp_numero: '',
|
||||
email_remitente: '',
|
||||
};
|
||||
|
||||
/** Variables permitidas en las plantillas (espejo del backend). */
|
||||
export const NOTIF_VARIABLES = ['nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor', 'sucursal', 'telefono'] as const;
|
||||
export const NOTIF_VARIABLES = ['nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor', 'sucursal', 'telefono', 'enlace', 'formulario'] as const;
|
||||
|
||||
/** Ventanas sugeridas para los chips de recordatorio. */
|
||||
export const NOTIF_VENTANAS: { horas: number; label: string }[] = [
|
||||
@@ -1224,6 +1227,114 @@ export interface BookingCita {
|
||||
telefono_contacto: string;
|
||||
}
|
||||
|
||||
// ---- Formularios digitales y consentimientos ----
|
||||
export type FormularioCampoTipo =
|
||||
| 'texto' | 'texto_largo' | 'numero' | 'fecha' | 'seleccion' | 'multi_seleccion'
|
||||
| 'checkbox' | 'escala' | 'firma' | 'foto' | 'archivo' | 'consentimiento';
|
||||
export type FormularioPlantillaTipo = 'consentimiento' | 'expediente' | 'encuesta' | 'autorizacion';
|
||||
export type FormularioEstado = 'borrador' | 'enviado' | 'llenado' | 'cancelado';
|
||||
export type FormularioMomento = 'antes' | 'despues' | 'otro';
|
||||
export type FormularioCanal = 'whatsapp' | 'email' | 'enlace';
|
||||
|
||||
export interface FormularioCampo {
|
||||
clave: string;
|
||||
etiqueta: string;
|
||||
tipo: FormularioCampoTipo;
|
||||
obligatorio: boolean;
|
||||
ayuda: string;
|
||||
opciones: string[];
|
||||
/** Solo para tipo foto/archivo: para la galería antes/después del expediente. */
|
||||
momento?: FormularioMomento;
|
||||
}
|
||||
|
||||
export interface FormularioPlantilla {
|
||||
id: number;
|
||||
name: string;
|
||||
tipo: FormularioPlantillaTipo;
|
||||
tipo_label?: string;
|
||||
activo: boolean;
|
||||
version: number;
|
||||
descripcion: string;
|
||||
campos: FormularioCampo[];
|
||||
texto_legal: string;
|
||||
requiere_firma: boolean;
|
||||
requiere_foto: boolean;
|
||||
duracion_estimada_min: number;
|
||||
secuencia: number;
|
||||
respuestas_count: number;
|
||||
sucursales?: { id: number; name: string }[];
|
||||
servicios?: { id: number; name: string }[];
|
||||
}
|
||||
|
||||
export interface FormularioAdjunto {
|
||||
id: number;
|
||||
campo_clave: string;
|
||||
kind: FormularioMomento;
|
||||
kind_label?: string;
|
||||
name: string;
|
||||
mimetype: string;
|
||||
notes: string;
|
||||
create_date: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface FormularioFoto extends FormularioAdjunto {
|
||||
respuesta_id: number;
|
||||
respuesta_name: string;
|
||||
plantilla: string;
|
||||
cita_id: number | null;
|
||||
}
|
||||
|
||||
export interface FormularioRespuesta {
|
||||
id: number;
|
||||
name: string;
|
||||
plantilla_id: number;
|
||||
plantilla: string;
|
||||
plantilla_tipo?: FormularioPlantillaTipo;
|
||||
plantilla_version: number;
|
||||
partner_id: number;
|
||||
patient: string;
|
||||
cita_id: number | null;
|
||||
estado: FormularioEstado;
|
||||
estado_label: string;
|
||||
canal_envio: string;
|
||||
creado_por_empleado: boolean;
|
||||
tiene_firma: boolean;
|
||||
firma_nombre: string;
|
||||
enviado_at: string | null;
|
||||
llenado_at: string | null;
|
||||
create_date: string | null;
|
||||
fotos_count: number;
|
||||
// Solo en el detalle
|
||||
respuestas?: Record<string, unknown>;
|
||||
campos?: FormularioCampo[];
|
||||
texto_legal?: string;
|
||||
notas?: string;
|
||||
firma_url?: string | null;
|
||||
fotos?: FormularioAdjunto[];
|
||||
}
|
||||
|
||||
export interface FormularioPublicoConfig {
|
||||
status: string;
|
||||
brand: { nombre: string; logo_claro: string; logo_oscuro: string; colores: { primario: string; acento: string } };
|
||||
terms: GlossaryTerms;
|
||||
paciente: { nombre: string };
|
||||
cita: { fecha: string | null; hora: string | null; servicio: string | null } | null;
|
||||
estado: FormularioEstado;
|
||||
ya_llenado: boolean;
|
||||
plantilla: {
|
||||
nombre: string;
|
||||
tipo: FormularioPlantillaTipo;
|
||||
tipo_label?: string;
|
||||
descripcion: string;
|
||||
texto_legal: string;
|
||||
requiere_firma: boolean;
|
||||
requiere_foto: boolean;
|
||||
duracion_estimada_min: number;
|
||||
campos: FormularioCampo[];
|
||||
};
|
||||
}
|
||||
|
||||
export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
|
||||
nombre: BRAND_DEFAULT.nombre,
|
||||
telefono: '',
|
||||
@@ -1926,6 +2037,88 @@ export const odooApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---- Formularios digitales: plantillas ----
|
||||
async getFormularioPlantillas(opts?: { includeInactive?: boolean; tipo?: string }): Promise<{ status: string; plantillas: FormularioPlantilla[]; total: number; message?: string }> {
|
||||
const params: Record<string, string | number> = {};
|
||||
if (opts?.includeInactive) params.include_inactive = 1;
|
||||
if (opts?.tipo) params.tipo = opts.tipo;
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/formularios/plantillas`, { params });
|
||||
return data;
|
||||
},
|
||||
|
||||
async getFormularioPlantilla(id: number): Promise<{ status: string; plantilla: FormularioPlantilla; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/formularios/plantillas/${id}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async createFormularioPlantilla(payload: Partial<FormularioPlantilla> & { sucursal_ids?: number[]; servicio_ids?: number[] }): Promise<{ status: string; plantilla: FormularioPlantilla; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/formularios/plantillas`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateFormularioPlantilla(id: number, payload: Partial<FormularioPlantilla> & { sucursal_ids?: number[]; servicio_ids?: number[] }): Promise<{ status: string; plantilla: FormularioPlantilla; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/formularios/plantillas/${id}`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteFormularioPlantilla(id: number): Promise<{ status: string; desactivada: boolean; message: string }> {
|
||||
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/formularios/plantillas/${id}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---- Formularios digitales: respuestas ----
|
||||
async getFormularioRespuestas(params?: Record<string, string | number>): Promise<{ status: string; respuestas: FormularioRespuesta[]; total: number; page: number; page_size: number; total_pages: number; resumen: Record<string, number>; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/formularios/respuestas`, { params });
|
||||
return data;
|
||||
},
|
||||
|
||||
async getFormularioRespuesta(id: number): Promise<{ status: string; respuesta: FormularioRespuesta; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/formularios/respuestas/${id}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async getFormularioFotos(params: { partner_id?: number; respuesta_id?: number }): Promise<{ status: string; total: number; fotos: FormularioFoto[]; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/formularios/fotos`, { params });
|
||||
return data;
|
||||
},
|
||||
|
||||
async enviarFormulario(payload: { plantilla_id: number; partner_id: number; cita_id?: number | null; canal: FormularioCanal }): Promise<{ status: string; respuesta: FormularioRespuesta; token: string; enlace: string; enviado_por: string; notificado: boolean; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/formularios/enviar`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async llenarFormulario(id: number, payload: { respuestas: Record<string, unknown>; firma_imagen?: string; firma_nombre?: string }): Promise<{ status: string; respuesta: FormularioRespuesta; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/formularios/respuestas/${id}/llenar`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async adjuntarFormulario(payload: { respuesta_id: number; campo_clave?: string; kind?: FormularioMomento; name?: string; file: string }): Promise<{ status: string; adjunto: FormularioAdjunto; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/formularios/adjuntar`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
// URL del binario (firma/foto) con el token de sesión en query para <img src>
|
||||
formularioArchivoUrl(url: string): string {
|
||||
const token = localStorage.getItem('skeen_token') || '';
|
||||
return `${ODOO_BASE}${url}?token=${encodeURIComponent(token)}`;
|
||||
},
|
||||
|
||||
// ---- Portal público de formularios (sin autenticación) ----
|
||||
async getFormularioPublicoConfig(token: string): Promise<FormularioPublicoConfig> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/public/formulario/config`, { params: { token } });
|
||||
return data;
|
||||
},
|
||||
|
||||
async getFormularioPublicoEstado(token: string): Promise<{ status: string; estado: FormularioEstado; ya_llenado: boolean; paciente: { nombre: string }; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/public/formulario`, { params: { token } });
|
||||
return data;
|
||||
},
|
||||
|
||||
async llenarFormularioPublico(payload: { token: string; respuestas: Record<string, unknown>; firma_imagen?: string; firma_nombre?: string; fotos?: { campo_clave?: string; kind?: FormularioMomento; name?: string; data_url: string }[]; hp?: string }): Promise<{ status: string; message: string; errores?: string[] }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/public/formulario/llenar`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---- Notificaciones y recordatorios de citas ----
|
||||
async getNotificaciones(params?: Record<string, string | number>): Promise<NotificacionesResponse> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/notificaciones`, { params });
|
||||
|
||||
Reference in New Issue
Block a user