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.tsconvierte á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.columnsde 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.
- Texto seguido (párrafos, títulos, listas, tablas, citas) → un bloque Texto, editado en la
hoja con TipTap y el mismo esquema (
- Por eso no cambian guardar, emitir, congelar ni imprimir.
health_missing_required_nodesigue 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/dynamicyssr: falsesolo 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 variantesband,sub,badgeysign(línea de firma), y nodobox(recuadro unido a su sección); table.attrs.variant:form(ficha con etiquetas sombreadas) ogrid(rejilla de datos), concolspan/rowspan;columns.attrs.variant:cut(línea de corte de la receta duplicada) oframed;doc.attrs:baseSize(letra de la hoja) yfontFamily: 'clinical'(la pila de sistema del kit). Viajan con el cuerpo y quedan congeladas endoc_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):
| Bloque | Nodo | Se reconoce cuando |
|---|---|---|
| Título | heading | el texto es llano (sin marcas) y solo trae nivel y alineación |
| Lista | bulletList/orderedList | cada punto es un listItem con UN párrafo sin atributos |
| Firmas | columns | cada columna tiene una única section de variante sign |
| Casillas | paragraph | el 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:
- La reparación no llegaba.
20261025000001solo reemplaza la versión vigenteWHERE 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. Ybodyno se reescribía nunca, así que el texto aplanado contradecía al cuerpo. - Un token roto costaba la plantilla entera.
aBloquesdescartaba TODO el árbol sihasBrokenTokensencontraba uno, y se caía adocFromText(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. - Tokens fuera del catálogo.
field.condiciones,field.medicacionyfield.notas_fichase rellenan al imprimir la historia (clinical-record-print-view) pero no estaban enDOCUMENT_TOKENS: no salían en «Insertar dato» yunknownTokensimpedí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
SlotComponentde 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.