fase 2.5: formularios digitales y consentimientos (modulo skeen_formularios: plantillas con 12 tipos de campo, versionado, respuestas con firma PNG y fotos antes/despues, portal publico con token HMAC y limite de subida, historial por paciente, permisos y notificacion de envio)

This commit is contained in:
2026-10-11 09:48:28 +00:00
parent 91f909a6d0
commit 9286ab8ee2
30 changed files with 3545 additions and 12 deletions

View File

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