Saltar al contenido principal

0006 — Editor de plantillas por bloques (Puck) sobre el árbol v2

Estado: propuesta (en QA) · Fecha: 2026-09-19 · segunda parte el mismo día

Contexto​

Las plantillas de documentos (recetas, certificados, HCU, pedidos) se editaban de dos formas:

  • TipTap de página completa (esquema v2). Da formato de texto, pero no deja componer la página: poner la firma al lado de las indicaciones o mover el QR exige conocer columnas, saltos y bloques insertados a mano en una barra de 40 botones.
  • HTML saneado (esquema v3). Da control total, pero un médico no escribe HTML.

El pedido era algo como el constructor de sitios de Odoo: arrastrar bloques de texto, imágenes y datos a la hoja y editarlos en el sitio. Además, el papel no es solo A4 vertical: hay recetarios A5, recetas duplicadas en A4 apaisado y papeles de imprenta con medidas propias.

Decisión​

Puck (@puckeditor/core, MIT) como editor por bloques, sin formato de datos nuevo.

  • Lo que se guarda sigue siendo el árbol v2 (DocumentDoc). components/document/blocks/doc-blocks.ts convierte árbol → bloques al abrir y bloques → árbol en cada cambio. La conversión no pierde nada y está cubierta por pruebas de ida y vuelta.
    • Texto seguido (párrafos, títulos, listas, tablas, citas) → un bloque Texto, editado en la hoja con TipTap y el mismo esquema (buildDocExtensions).
    • image, spacer, horizontalRule, pageBreak → un bloque cada uno, con ajustes simples.
    • columns de 2 a 4 → bloque Columnas con una ranura de Puck por columna.
    • Nodos de dominio (signatureBlock, qr, prescriptionItems, hcu*…) → un bloque cada uno.
    • Lo que no encaja (p. ej. 5 columnas) → bloque Raw, que conserva el nodo entero.
  • Por eso no cambian guardar, emitir, congelar ni imprimir. health_missing_required_node sigue recorriendo el mismo árbol. El editor en sí no necesitó migración; la de la segunda parte solo convierte datos.
  • Es el único editor de plantillas (segunda parte, abajo). Los editores de texto y HTML salen de la pantalla de plantillas; sus renderizadores se quedan para reimprimir lo emitido.
  • Puck se carga con next/dynamic y ssr: false solo en la pantalla de plantillas. Las vistas de impresión no lo descargan.

Papel a medida en el mismo campo size. DocPageConfig.size acepta los papeles con nombre (A4, A5, A3, Letter, Legal) o el literal de CSS @page "<ancho>mm <alto>mm". health_build_layout_snapshot ya copia size de la plantilla al congelar, así que un campo width/height aparte se habría perdido al emitir, salvo que se cambiara la función. El valor se parsea con una expresión cerrada y se acota a 50–1000 mm (doc-page.ts); nunca se vuelca tal cual. El editor, la vista previa y el @page de DocSheet usan el mismo catálogo.

TipTap fijado con resolutions. Puck declara @tiptap/*@^3.11.1, y yarn subía una segunda copia (3.31.3) que además reemplazaba la 3.30.2 del proyecto. Las resolutions de frontend/package.json dejan una sola copia, la 3.30.2. Si se actualiza TipTap, se actualizan las dos cosas juntas.

Descartado​

  • GrapesJS: es el modelo de Odoo (fragmentos HTML y CSS). Vuelve al problema de sanear HTML libre, deja posicionar con estilos que rompen la hoja y no es React.
  • Craft.js: la misma idea que Puck, pero de más bajo nivel y con menos mantenimiento.
  • Builder.io y Plasmic: guardan el contenido en su nube. Con plantillas que se rellenan con PHI, queda descartado.
  • Esquema v4 con el JSON de Puck: obligaba a tocar el CHECK, las RPC, la emisión y todas las vistas de impresión, sin ganar nada frente a convertir al árbol v2.

Segunda parte: editor único y plantillas de sistema en bloques (2026-09-19)​

Decidido con el usuario: el editor por bloques es el definitivo, las plantillas deben verse igual que hoy, y la versión vigente de cada plantilla de sistema se reemplaza (no se crea una nueva).

Papelería clínica en el árbol v2. Las plantillas de sistema usaban el kit HTML hcu-*. Para que se vean igual en bloques, el modelo ganó piezas genéricas (sirven a cualquier plantilla):

  • nodo section, con las variantes band, sub, badge y sign (línea de firma), y nodo box (recuadro unido a su sección);
  • table.attrs.variant: form (ficha con etiquetas sombreadas) o grid (rejilla de datos), con colspan/rowspan;
  • columns.attrs.variant: cut (línea de corte de la receta duplicada) o framed;
  • doc.attrs: baseSize (letra de la hoja) y fontFamily: 'clinical' (la pila de sistema del kit). Viajan con el cuerpo y quedan congeladas en doc_snapshot.

Al imprimir se replica la regla CSS del kit: una sección cuyo recuadro queda sin datos, o que precede a un hueco clínico vacío, no sale (doc-prune.ts; en la vista previa sí sale).

Conversión. import/hcu-to-doc.ts traduce el kit pieza a pieza, con pruebas. No hay versión en SQL a propósito. La migración 20261025000001_system_templates_to_blocks.sql son datos que generó ese convertidor: 23 plantillas en HTML pasan a v2. Antes, congela doc_snapshot en los documentos que no lo tuvieran. Se probó en PGlite: idempotencia, versiones anteriores sin tocar, personalizadas sin tocar y documentos emitidos sin cambios.

Hallazgo. 20260930000001 quitaba el membrete con regexp_replace usando \s*…[\s\S]*?. En Postgres la primera cuantificación fija la voracidad de toda la expresión, así que el *? fue voraz y 13 plantillas de sistema en PROD (12 en DEV) quedaron en <div class="hcu-sheet">. No se había emitido ningún documento con ellas. Esta migración las restaura desde 20260918000002.

Plantillas personalizadas en HTML (3 en DEV, 0 en PROD). Se convierten al abrirlas en el editor, porque la base no puede ejecutar el convertidor, y quedan en bloques al guardar. La que se clonó de una plantilla ya vacía no se puede recuperar: hay que volver a personalizarla.

Diferencia conocida: un dato vacío dentro de un texto sale ______ (para rellenar a mano), como en el resto de documentos en bloques. En HTML salía en blanco.

Tercera parte: bloques de verdad, colores y espacio (2026-09-19)​

El usuario lo resumió así: las plantillas deben repetir al 100 % el uso de bloques, para que sean una base sólida al personalizarlas; además, colores personalizables y más espacio al editar.

Nada de tablas escondidas en un texto. Dos bloques nuevos cubren lo que quedaba:

  • Ficha de datos (DataSheet): una tabla de pares etiqueta|valor se edita como una LISTA de datos (etiqueta, valor con tokens, «ocupa toda la fila»), con 1 o 2 por fila y estilo ficha o rejilla. Se reparte sola en filas. Solo se reconoce como ficha si ese reparto reproduce la tabla EXACTAMENTE; si no, se queda como tabla dentro de un texto. Las 44 tablas de las plantillas de sistema encajan.
  • Sección con recuadro (SectionBox): la banda y su recuadro son un bloque, no dos.

Con eso, las 23 plantillas quedan compuestas solo de bloques (Ficha, Sección con recuadro, Texto de un párrafo, Columnas y bloques de dominio), y la ida y vuelta sigue siendo exacta. El árbol v2 no cambió: los bloques nuevos son otra forma de VER los mismos nodos, así que la migración de datos sigue valiendo.

Colores. DocTheme.colors gana band (banda de sección) y label (fondo de las etiquetas); sin ellos se usa la paleta del kit. Y section.attrs.color permite el color de una banda concreta, por encima del de la marca. Todo pasa por sanitizeColor (#rrggbb o nada).

Espacio. El editor se abre a pantalla completa desde «Diseñar», con la paleta y los ajustes plegables. Escalar el lienzo no es viable: probado con zoom y con transform: scale(), y en los dos casos dnd-kit deja de registrar dónde cae el bloque y el arrastre se pierde sin avisar. Por eso se edita siempre a tamaño real y, para ver la página entera, está «Ver hoja completa», que es de solo lectura y ahí sí escala.

Marca y membrete, también por bloques. El formulario de Marca pierde los modos «Simple» y «HTML»: membrete y pie se editan con DocBlockEditor (sheet="band", sin alto de página) y se guardan como DocNode[], que DocSheet ya sabía pintar. Un membrete guardado en HTML se convierte al abrirlo. Para que la conversión no aplane la maqueta, el importador genérico reconoce ahora un div con display:flex|grid y dos o más hijos como columns, y propaga la alineación del contenedor a sus párrafos (el bloque de contacto va a la derecha).

Cuidado con DOMParser: convertir HTML solo funciona en el navegador. Inicializar el estado del formulario con una conversión rompía el render en servidor («DOMParser is not defined»); el contenido real se carga en el efecto de cliente.

Cuarta parte: personalizar no chocaba solo, y el ancho manda (2026-09-19)​

«No se pudo guardar» al personalizar. No era el guardado: era el clonado. Las plantillas de sistema son únicas por (code, locale) —conviven receta genérica y receta de Ecuador, y el catálogo de un tenant ecuatoriano enseña las dos—, pero la copia del tenant era única por (tenant_id, code). Personalizar la segunda variante, o volver a personalizar una plantilla ya personalizada, violaba el índice y toErrorCode traducía cualquier error desconocido a write_failed, que en pantalla se lee «No se pudo guardar».

20261026000001: el índice del tenant (y el de profesional) mete el locale dentro, y health_clone_template devuelve {id, created} — si la copia ya existe la devuelve con created=false y la interfaz la abre en vez de acusar un fallo. La acción acepta las dos formas (uuid pelado y jsonb) porque durante el despliegue conviven. De paso, la función pasa a tener REVOKE … FROM PUBLIC, anon y GRANT explícito, que le faltaban. Probado en PGlite: rojo con la función actual, verde con la migración.

El ancho. La hoja no se puede escalar (rompe el arrastre), así que lo que se negocia son los paneles, no el papel. doc-block-layout.ts hace la cuenta —hoja a tamaño real contra ancho disponible— y decide: mientras el usuario no toque los botones, los paneles se pliegan solos (los ajustes primero, que son más anchos); y si abre uno que no cabe, ese panel flota sobre el lienzo en vez de estrecharlo. Un A4 apaisado pide 1171 px: con los dos paneles fijos no cabía en ningún portátil.

Quinta parte: bloques de verdad para los formularios (2026-09-20)​

El usuario, mirando los formatos reales: «veo tablas con celdas de color y no hay bloques para replicarlos; la barra del bloque de texto es muy pobre; la lista de datos tiene que buscarse; y hacen falta más bloques esenciales».

Tabla como bloque. La tabla sale del bloque de texto y es un bloque propio (Table), con el nodo table entero dentro, así que la ida y vuelta es exacta por construcción. Se arrastra, se duplica y tiene su estilo en los ajustes; el contenido se edita en la hoja con la barra.

Color de celda. tableCell|tableHeader.attrs.background (#rrggbb). Lo registra el esquema del editor (se ve mientras se edita), lo pinta doc-nodes con IMPRIME_FONDOS y pasa por sanitizeColor. El selector se dobla (&&) porque el estilo de la ficha pinta & th desde la tabla y ganaba por especificidad. El importador de HTML, que hasta ahora tiraba colspan, rowspan y los fondos, los conserva; colorToHex normaliza el rgb() del navegador y los hexadecimales cortos, y descarta lo transparente.

Bloques nuevos, todos reconocidos del árbol SOLO si la vuelta reproduce el nodo exactamente (bloqueReconocido compara antes de aceptar):

BloqueNodoSe reconoce cuando
Títuloheadingel texto es llano (sin marcas) y solo trae nivel y alineación
ListabulletList/orderedListcada punto es un listItem con UN párrafo sin atributos
Firmascolumnscada columna tiene una única section de variante sign
Casillasparagraphel texto es «☐ Opción ☒ Opción», separadas por tres espacios

Barra del bloque de texto: tachado, tamaño de letra, cita, justificado, color de texto, resaltado, quitar formato y, con el cursor dentro de una tabla, añadir/quitar filas y columnas, combinar celdas, fila de cabecera, fondo de celda (y quitarlo) y borrar la tabla.

«Insertar dato» con buscador (DocTokenPicker, compartido con el campo de una línea de la ficha): busca por etiqueta, clave y origen, sin acentos, y Enter inserta el primero. Son más de cincuenta datos: el menú plano obligaba a recorrerlos.

Los iconos nuevos se añadieron a icon-sets.ts con su body real; uno que no esté registrado se descarga de la API de Iconify en caliente y parpadea.

Sexta parte: la marca también es una pantalla de diseño (2026-09-20)​

DocBlockEditor vivía DENTRO de la columna de ajustes de Marca (360–460 px): la hoja no cabía y todo se veía apretado. Ahora membrete y pie salen de ahí a una pantalla completa, igual que las plantillas: cabecera con el selector Membrete | Pie —se edita uno a la vez para que la hoja tenga el ancho entero—, Guardar y cerrar. La tarjeta de la columna se queda con el botón Diseñar membrete y pie y el de restablecer, y la columna sube a 420–560 px.

Fuera el conmutador Editar | Vista previa de la pantalla de plantillas. Desde que el editor es un diálogo a pantalla completa, la pantalla de la plantilla YA es la vista previa; el conmutador solo elegía entre pintar o no los ______ de los datos que faltan, y se queda la versión que los marca, que es la útil para una plantilla. Con él se van sus dos claves de i18n.

Séptima parte: anchos, espaciado y catálogo sin duplicadas (2026-09-20)​

Lo que faltaba para editar de verdad los formatos del MSP.

La barra se parte en varias filas (flexWrap) en vez de esconder botones tras un desplazamiento horizontal: con las tablas y los colores ya no cabía en una, y los de borrar tabla —que están al final— no se veían. El separador pasa a alto fijo, porque flexItem mide 0 cuando la fila se parte.

Borrar la tabla borra el bloque. El bloque Tabla ES su tabla: dejar un bloque de texto vacío detrás era un resto que había que quitar a mano (removeWhenEmpty en DocTextBlock).

Columnas redimensionables. column.attrs.width en por ciento (5–95). Se arrastra el separador en la hoja (doc-columns-block.tsx), lo que gana una columna lo pierde su vecina (repartirAncho, puro y con pruebas) y se entrega a Puck al SOLTAR, no en cada píxel. El asa se registra con registerOverlayPortal(..., { disableDrag: true }), o arrastraría el bloque entero. El renderizador aplica flex: 0 0 W%; sin ancho, 1 1 0 como siempre.

Espaciado vertical configurable, a dos niveles: RenderNode envuelve CUALQUIER bloque con marginTop/marginBottom (el párrafo y el título ya se lo ponían ellos), los ajustes de bloque lo editan dentro de extra —el mismo atributo que ya traía la importación de Google Docs, no uno nuevo— y la barra del texto ajusta interlineado y huecos del párrafo donde está el cursor.

Fidelidad de la ficha: & th { width: 16% }, como .hcu-id th del kit. Con table-layout: fixed y sin anchos, una ficha de dos datos por fila salía en cuatro columnas iguales y la etiqueta se comía el sitio del dato. Las celdas admiten además attrs.width propio, que el importador saca del HTML.

Reconversión de las 23 plantillas: 21 salen idénticas a lo aplicado y 2 (certificado_movilidad, certificado_ocupacional) ganan el ancho real de su columna (62 %), porque son las únicas que no usan el kit hcu-* y pasan por el importador genérico, que hasta ahora tiraba los anchos. 20261027000001 reemplaza la versión vigente solo si sigue siendo la conversión anterior: si alguien la tocó, manda lo suyo.

Duplicadas del catálogo. Seis documentos existen como genérica y como ecuatoriana (certificado_medico, hcu_completa, orden_examenes, receta, receta_duplicada, receta_vertical) y el listado enseñaba las dos. Decisión del usuario: no borrar, esconder. health_list_document_templates oculta la genérica de sistema cuando existe una del país del tenant; la copia del tenant no se esconde nunca, y un tenant sin país sigue viendo la genérica. Probado en PGlite (14 comprobaciones, rojo antes que verde). Las personalizadas no se tocan.

Octava parte: las plantillas base, auditadas (2026-09-21)​

«Al personalizar el certificado general no se ve nada, solo el membrete», y en otras «tokens vacíos». Tres causas, no una:

  1. La reparación no llegaba. 20261025000001 solo reemplaza la versión vigente WHERE schema_version = 3. Una plantilla rota que alguien ya hubiera abierto y guardado por bloques quedó en esquema 2 y vacía, fuera del alcance de la reparación. Y body no se reescribía nunca, así que el texto aplanado contradecía al cuerpo.
  2. Un token roto costaba la plantilla entera. aBloques descartaba TODO el árbol si hasBrokenTokens encontraba uno, y se caía a docFromText(body) —que suele estar vacío—, sin un solo aviso. Ahora se limpia el token y se conserva el documento (dropBrokenTokens), y la pantalla avisa cuando el cuerpo está realmente vacío.
  3. Tokens fuera del catálogo. field.condiciones, field.medicacion y field.notas_ficha se rellenan al imprimir la historia (clinical-record-print-view) pero no estaban en DOCUMENT_TOKENS: no salían en «Insertar dato» y unknownTokens impedía guardar la HCU.

El dato sale del SQL. Las 25 plantillas base viven ahora en supabase/templates/base/*.json (cuerpo, nombre, texto aplanado y bloques obligatorios). De ahí salen dos cosas: la migración 20261028000001 —que reemplaza la versión vigente sin filtrar por esquema y rellena las copias de tenant vacías— y base-templates.test.ts, que audita las 25: cuerpo no vacío, tokens del catálogo, bloques obligatorios presentes, ninguna sección o recuadro huérfano y ningún aviso de conversión. Es lo que impide que «listas para usar» se pierda en la siguiente migración.

De paso, orden_examenes (genérica y EC) deja el TipTap plano de 20260917000001 y pasa al mismo kit clínico que el resto.

Novena parte: membretes de consultorio y tope de imágenes (2026-09-21)​

El membrete nace por bloques. brand-letterhead-blocks.ts sustituye al HTML de fábrica, con dos reglas que el anterior no cumplía:

  • Lo que puede faltar va optional: teléfono, correo, especialidad, credenciales, dirección y folio. Sin eso, un consultorio recién dado de alta imprimía ______ en cada receta —y «Tel. ______» cuando la etiqueta iba delante, así que tampoco hay etiquetas fijas.
  • Interlineado 1,1 y sin márgenes entre líneas. En una receta A5 el membrete se comía tres o cuatro indicaciones.

Las columnas llevan ancho propio (16/52/32), así que el logo no se lleva un tercio de la hoja.

Los dos consultorios de producción (20261029000001): MC Especialistas Médicos y Dr. Jimmy Zambrano Chávez pasan a ese membrete, con su tema a interlineado 1,15. Y se corrige un error que solo se ve mirando los datos: el membrete del Dr. Zambrano referenciaba un activo del tenant MC, que fuera de su tenant no carga nunca. La migración solo toca los membretes que sigan en HTML: uno ya rediseñado por bloques manda.

Tope de tres activos por tipo (20261029000002). La biblioteca crecía sin freno. El tope vive en un trigger de doc_assets, no solo en el botón, porque registerAsset es una server action —un endpoint HTTP público—. Solo cuentan los activos ACTIVOS: dar de baja libera sitio, y reactivar vuelve a contar (si no, se rodeaba el tope dando de baja y de alta la misma fila). La pantalla de marca lo dice antes de subir y ahora deja borrar también las imágenes de biblioteca, que entran al importar y no tenían forma de quitarse.

Consecuencias​

  • Una trampa ya encontrada: el SlotComponent de Puck es una función nueva en cada cambio de la columna. Montada como <Ranura />, desmontaba la columna entera en cada tecla. Por eso se llama como función (ranura({ … })).
  • Se añade una dependencia de terceros (Puck, alrededor de 100 paquetes transitivos, entre ellos happy-dom). Solo va en el paquete de la pantalla de plantillas.