pixelon 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,17 @@ export declare function usePlaceSuggestions(query: string, enabled: boolean): {
11
11
  } | null>;
12
12
  clear: () => void;
13
13
  };
14
+ /** SPEC-131 T6 — la dirección formateada de Google repite tramos.
15
+ *
16
+ * Medido: el `formattedAddress` de una calle de El Poblado vuelve como
17
+ * «Cl 10 #43-20, El Poblado, Medellín, El Poblado, Medellín, Antioquia», porque el nombre del
18
+ * lugar ya trae el barrio y la ciudad que luego añade la parte administrativa. Esa cadena se
19
+ * guarda tal cual y aparece en el chat, en la ficha, en la tabla y en Conversaciones.
20
+ *
21
+ * Se comparan los tramos sin tildes ni mayúsculas, que es donde estaba el otro caso visto
22
+ * («Itagüí, Itagüi»), y se conserva el primero de cada uno: el orden de una dirección va de lo
23
+ * particular a lo general, así que el que sobra siempre es el segundo. */
24
+ export declare function limpiarDireccion(address: string): string;
14
25
  /**
15
26
  * Traduce una coordenada a una dirección legible.
16
27
  * Requiere Geocoding habilitado en la clave del navegador; si no lo está, devuelve null y el
@@ -15,6 +15,12 @@ export interface ServicesMapConfig {
15
15
  data: {
16
16
  list: EndpointRef;
17
17
  };
18
+ /** SPEC-131 T6 — qué pide el mapa cuando nadie acota nada.
19
+ *
20
+ * Un mapa de seguimiento sin filtro devuelve el historial entero, y eso es lo que recibía quien
21
+ * preguntaba «¿ya viene mi grúa?». El `filter` de la instancia PISA esto: cuando quien emite el
22
+ * componente sí sabe qué acotar, manda él. */
23
+ defaultFilter?: Record<string, unknown>;
18
24
  mapping: ServicesMapMapping;
19
25
  /** Icono para todos los puntos cuando la fila no trae uno. */
20
26
  defaultIcon?: string;
@@ -14,9 +14,16 @@ export interface UseAutoScrollOptions {
14
14
  * desconectarlo), dos envíos seguidos dejaban dos observadores compitiendo por mover el scroll,
15
15
  * y ninguno se limpiaba al desmontar.
16
16
  */
17
+ /** SPEC-131 T3 — cuánto espacio hay que reservar al final para que el objetivo pueda subir arriba.
18
+ *
19
+ * Lo que falte y nada más: si desde el objetivo hasta el final ya hay una pantalla de contenido,
20
+ * no hace falta reservar nada. Reservar `clientHeight` a ciegas mete una pantalla de blanco al
21
+ * final, y con el scroll topado eso deja la conversación entera por encima del borde superior. */
22
+ export declare function reservaNecesaria(clientHeight: number, scrollHeight: number, targetTop: number): number;
17
23
  export declare const useAutoScroll: ({ messagesEndRef, messagesContainerRef, }: UseAutoScrollOptions) => {
18
24
  scrollToLastUserMessage: () => void;
19
25
  pinLastUserMessage: () => void;
20
26
  scrollToProactive: () => void;
21
- scrollToBottom: () => void;
27
+ scrollToBottom: (suave?: boolean) => void;
28
+ aterrizarAlFinal: () => void;
22
29
  };
@@ -0,0 +1,13 @@
1
+ export interface LineaResumen {
2
+ label: string;
3
+ valor: string;
4
+ }
5
+ interface CampoResumible {
6
+ name: string;
7
+ label?: string;
8
+ moleculeType?: string;
9
+ uiProps?: Record<string, unknown>;
10
+ }
11
+ /** Lo que se va a enviar, en el orden del formulario y sin los campos vacíos. */
12
+ export declare function resumenParaConfirmar(campos: ReadonlyArray<CampoResumible>, datos: Record<string, unknown> | null | undefined): LineaResumen[];
13
+ export {};
@@ -51,6 +51,12 @@ export interface RenderFieldConfig {
51
51
  formatValue?: (value: unknown, row: unknown) => React.ReactNode;
52
52
  /** Función para parsear el valor antes de submit */
53
53
  parseValue?: (value: unknown) => unknown;
54
+ /** SPEC-131 T7 — cuántas columnas ocupa el campo en el formulario.
55
+ *
56
+ * En un grid las celdas de una fila comparten altura, así que un campo que se despliega —el de
57
+ * ubicación, que abre un mapa de verificación al elegir la dirección— deja a su hermana con
58
+ * cientos de píxeles de blanco al lado. Los campos que crecen ocupan la fila entera. */
59
+ gridSpan?: number;
54
60
  }
55
61
  /**
56
62
  * Estado de paginación
package/dist/index.esm.js CHANGED
@@ -10924,6 +10924,7 @@ const Drawer = ({
10924
10924
  "data-testid": dataTestId,
10925
10925
  scrollIndicatorOffset = 1
10926
10926
  }) => {
10927
+ const idTitulo = useId();
10927
10928
  const [isMobile, setIsMobile] = useState(false);
10928
10929
  const contentRef = useRef(null);
10929
10930
  const [showScrollIndicator, setShowScrollIndicator] = useState(false);
@@ -11047,14 +11048,14 @@ const Drawer = ({
11047
11048
  style: getDrawerStyles(),
11048
11049
  role: "dialog",
11049
11050
  "aria-modal": "true",
11050
- "aria-labelledby": title ? "drawer-title" : void 0,
11051
+ "aria-labelledby": title ? idTitulo : void 0,
11051
11052
  "data-testid": dataTestId,
11052
11053
  children: /* @__PURE__ */ jsxs("div", { className: `${COMPONENT_PREFIX}drawer__viewport`, children: [
11053
11054
  (title || showCloseButton || headerExtra) && /* @__PURE__ */ jsxs("div", { className: `${COMPONENT_PREFIX}drawer__header`, children: [
11054
11055
  title && /* @__PURE__ */ jsx(
11055
11056
  "h2",
11056
11057
  {
11057
- id: "drawer-title",
11058
+ id: idTitulo,
11058
11059
  className: `${COMPONENT_PREFIX}drawer__title`,
11059
11060
  children: title
11060
11061
  }
@@ -13167,7 +13168,9 @@ function mapFieldsToFormFields(fields, mode) {
13167
13168
  readOnly: field.readOnly,
13168
13169
  placeholder: field.placeholder,
13169
13170
  uiProps: field.uiProps,
13170
- transformOnSubmit: field.parseValue
13171
+ transformOnSubmit: field.parseValue,
13172
+ gridSpan: field.gridSpan
13173
+ // SPEC-131 T7
13171
13174
  }));
13172
13175
  }
13173
13176
 
@@ -13347,6 +13350,47 @@ function calculateTotalPages(totalRecords, limit) {
13347
13350
  return Math.ceil(totalRecords / limit);
13348
13351
  }
13349
13352
 
13353
+ function etiquetaDeOpcion(valor, uiProps) {
13354
+ const opciones = uiProps?.options;
13355
+ if (!Array.isArray(opciones)) return null;
13356
+ const encontrada = opciones.find((o) => o && String(o.value) === String(valor));
13357
+ return encontrada?.label != null ? String(encontrada.label) : null;
13358
+ }
13359
+ function esNumerico(moleculeType) {
13360
+ return moleculeType === "INPUT_CURRENCY" || moleculeType === "INPUT_NUMBER";
13361
+ }
13362
+ function aTexto(valor, uiProps, moleculeType) {
13363
+ if (valor == null || valor === "") return "";
13364
+ if (typeof valor === "boolean") return valor ? "S\xED" : "No";
13365
+ if (esNumerico(moleculeType)) {
13366
+ const n = Number(String(valor).replace(/[^\d.-]/g, ""));
13367
+ if (Number.isFinite(n)) return n.toLocaleString();
13368
+ }
13369
+ if (typeof File !== "undefined" && valor instanceof File) return valor.name;
13370
+ if (Array.isArray(valor)) {
13371
+ const partes = valor.map((v) => aTexto(v, uiProps, moleculeType)).filter(Boolean);
13372
+ return partes.join(", ");
13373
+ }
13374
+ if (typeof valor === "object") {
13375
+ const o = valor;
13376
+ for (const clave of ["label", "address", "name", "text", "value"]) {
13377
+ if (typeof o[clave] === "string" && o[clave]) return o[clave];
13378
+ }
13379
+ return "";
13380
+ }
13381
+ return etiquetaDeOpcion(valor, uiProps) ?? String(valor);
13382
+ }
13383
+ function resumenParaConfirmar(campos, datos) {
13384
+ if (!datos) return [];
13385
+ const lineas = [];
13386
+ for (const campo of campos) {
13387
+ const valor = aTexto(datos[campo.name], campo.uiProps, campo.moleculeType);
13388
+ if (!valor) continue;
13389
+ lineas.push({ label: campo.label ?? campo.name, valor });
13390
+ }
13391
+ return lineas;
13392
+ }
13393
+
13350
13394
  const useAnchoPropio = () => {
13351
13395
  const ref = useRef(null);
13352
13396
  const [ancho, setAncho] = useState(null);
@@ -13791,6 +13835,10 @@ function Crud({
13791
13835
  return "Formulario";
13792
13836
  }
13793
13837
  };
13838
+ const resumenConfirmacion = useMemo(
13839
+ () => confirmAction === "create" || confirmAction === "edit" ? resumenParaConfirmar(formFields, pendingFormData) : [],
13840
+ [confirmAction, formFields, pendingFormData]
13841
+ );
13794
13842
  const getConfirmMessage = () => {
13795
13843
  switch (confirmAction) {
13796
13844
  case "create":
@@ -13930,6 +13978,10 @@ function Crud({
13930
13978
  className: `${COMPONENT_PREFIX}crud__confirm-drawer`,
13931
13979
  children: /* @__PURE__ */ jsxs("div", { className: `${COMPONENT_PREFIX}crud__confirm-content`, children: [
13932
13980
  /* @__PURE__ */ jsx("p", { className: `${COMPONENT_PREFIX}crud__confirm-message`, children: getConfirmMessage() }),
13981
+ resumenConfirmacion.length > 0 && /* @__PURE__ */ jsx("dl", { className: `${COMPONENT_PREFIX}crud__confirm-summary`, children: resumenConfirmacion.map((linea) => /* @__PURE__ */ jsxs("div", { className: `${COMPONENT_PREFIX}crud__confirm-summary-row`, children: [
13982
+ /* @__PURE__ */ jsx("dt", { children: linea.label }),
13983
+ /* @__PURE__ */ jsx("dd", { children: linea.valor })
13984
+ ] }, linea.label)) }),
13933
13985
  /* @__PURE__ */ jsxs("div", { className: `${COMPONENT_PREFIX}crud__confirm-actions`, children: [
13934
13986
  /* @__PURE__ */ jsx(
13935
13987
  Button,
@@ -15875,7 +15927,8 @@ function usePlaceSuggestions(query, enabled) {
15875
15927
  return {
15876
15928
  lat: typeof loc.lat === "function" ? loc.lat() : loc.lat,
15877
15929
  lng: typeof loc.lng === "function" ? loc.lng() : loc.lng,
15878
- address: String(place.formattedAddress ?? "")
15930
+ address: limpiarDireccion(String(place.formattedAddress ?? ""))
15931
+ // SPEC-131 T6
15879
15932
  };
15880
15933
  } catch {
15881
15934
  return null;
@@ -15885,13 +15938,27 @@ function usePlaceSuggestions(query, enabled) {
15885
15938
  );
15886
15939
  return { suggestions, resolve, clear: () => setSuggestions([]) };
15887
15940
  }
15941
+ function limpiarDireccion(address) {
15942
+ const normal = (s) => s.trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
15943
+ const vistos = /* @__PURE__ */ new Set();
15944
+ const tramos = [];
15945
+ for (const tramo of address.split(",")) {
15946
+ const limpio = tramo.trim();
15947
+ if (!limpio) continue;
15948
+ const clave = normal(limpio);
15949
+ if (vistos.has(clave)) continue;
15950
+ vistos.add(clave);
15951
+ tramos.push(limpio);
15952
+ }
15953
+ return tramos.join(", ");
15954
+ }
15888
15955
  function reverseGeocode(lat, lng) {
15889
15956
  return new Promise((resolve) => {
15890
15957
  const maps = window.google?.maps;
15891
15958
  if (!maps?.Geocoder) return resolve(null);
15892
15959
  new maps.Geocoder().geocode({ location: { lat, lng } }, (results, status) => {
15893
15960
  if (status !== "OK" || !results?.length) return resolve(null);
15894
- resolve(String(results[0].formatted_address ?? "") || null);
15961
+ resolve(limpiarDireccion(String(results[0].formatted_address ?? "")) || null);
15895
15962
  });
15896
15963
  });
15897
15964
  }
@@ -16048,6 +16115,10 @@ function toRenderField(f, loaded, eventBus, mapsApiKey, loadError) {
16048
16115
  showInModes: f.showInModes,
16049
16116
  required: f.required,
16050
16117
  placeholder: f.placeholder,
16118
+ // SPEC-131 T7 — la fila entera: al elegir la dirección se despliega el mapa de verificación
16119
+ // y, a media fila, su campo hermano se quedaba con ~400px de blanco al lado. Medido entre
16120
+ // «¿En qué situación está?» y «¿A dónde lo llevamos?» del formulario de solicitud.
16121
+ gridSpan: 2,
16051
16122
  uiProps: {
16052
16123
  // SPEC-074 — `error` viene del render de CUSTOM (DynamicForm ya lo pasaba) y aquí se
16053
16124
  // desechaba: el campo obligatorio más importante era el único mudo.
@@ -16672,6 +16743,8 @@ const ServicesMap = ({ config, eventBus, apiKey, filter, fill }) => {
16672
16743
  const { service, url, method, payloadMap } = config.data.list;
16673
16744
  void fetchViaEventBus(eventBus, service, url, method ?? "POST", {
16674
16745
  ...payloadMap,
16746
+ // SPEC-131 T6 — el del config primero, el de la instancia después: quien acota manda.
16747
+ ...config.defaultFilter ?? {},
16675
16748
  ...filter ?? {}
16676
16749
  }).then((res) => {
16677
16750
  if (!alive) return;
@@ -18825,6 +18898,10 @@ const useInputFocus = ({
18825
18898
  };
18826
18899
  };
18827
18900
 
18901
+ function reservaNecesaria(clientHeight, scrollHeight, targetTop) {
18902
+ const debajoDelObjetivo = scrollHeight - targetTop;
18903
+ return Math.max(0, clientHeight - debajoDelObjetivo);
18904
+ }
18828
18905
  const useAutoScroll = ({
18829
18906
  messagesEndRef,
18830
18907
  messagesContainerRef
@@ -18843,11 +18920,12 @@ const useAutoScroll = ({
18843
18920
  const container = messagesContainerRef.current;
18844
18921
  const spacer = messagesEndRef.current;
18845
18922
  if (!container || !spacer || !target) return;
18846
- spacer.style.minHeight = `${container.clientHeight}px`;
18923
+ spacer.style.minHeight = "0px";
18847
18924
  const containerRect = container.getBoundingClientRect();
18848
18925
  const msgRect = target.getBoundingClientRect();
18849
18926
  const padTop = parseFloat(getComputedStyle(container).paddingTop) || 0;
18850
18927
  const targetTop = msgRect.top - containerRect.top + container.scrollTop - padTop;
18928
+ spacer.style.minHeight = `${reservaNecesaria(container.clientHeight, container.scrollHeight, targetTop)}px`;
18851
18929
  container.scrollTo({ top: targetTop, behavior: "smooth" });
18852
18930
  }, [messagesContainerRef, messagesEndRef]);
18853
18931
  const last = useCallback((selector) => {
@@ -18889,15 +18967,33 @@ const useAutoScroll = ({
18889
18967
  });
18890
18968
  }, 150);
18891
18969
  }, [doScroll]);
18892
- const scrollToBottom = useCallback(() => {
18970
+ const aterrizarAlFinal = useCallback(() => {
18971
+ const container = messagesContainerRef.current;
18972
+ if (!container) return;
18973
+ resetSpacer();
18974
+ const hasta = Date.now() + 1e3;
18975
+ let ultimoAlto = -1;
18976
+ const pegar = () => {
18977
+ const c = messagesContainerRef.current;
18978
+ if (!c) return;
18979
+ if (c.scrollHeight !== ultimoAlto) {
18980
+ ultimoAlto = c.scrollHeight;
18981
+ c.scrollTop = c.scrollHeight;
18982
+ }
18983
+ if (Date.now() < hasta) requestAnimationFrame(pegar);
18984
+ };
18985
+ requestAnimationFrame(pegar);
18986
+ }, [messagesContainerRef, resetSpacer]);
18987
+ const scrollToBottom = useCallback((suave = true) => {
18893
18988
  cancelPending();
18894
18989
  resetSpacer();
18895
18990
  requestAnimationFrame(() => {
18991
+ const comportamiento = suave ? "smooth" : "auto";
18896
18992
  const container = messagesContainerRef.current;
18897
18993
  if (container) {
18898
- container.scrollTo({ top: container.scrollHeight, behavior: "smooth" });
18994
+ container.scrollTo({ top: container.scrollHeight, behavior: comportamiento });
18899
18995
  } else {
18900
- messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
18996
+ messagesEndRef.current?.scrollIntoView({ behavior: comportamiento, block: "end" });
18901
18997
  }
18902
18998
  });
18903
18999
  }, [cancelPending, resetSpacer, messagesContainerRef, messagesEndRef]);
@@ -18905,7 +19001,9 @@ const useAutoScroll = ({
18905
19001
  scrollToLastUserMessage,
18906
19002
  pinLastUserMessage,
18907
19003
  scrollToProactive,
18908
- scrollToBottom
19004
+ scrollToBottom,
19005
+ aterrizarAlFinal
19006
+ // SPEC-131 T3
18909
19007
  };
18910
19008
  };
18911
19009
 
@@ -19458,7 +19556,7 @@ const Chat = ({
19458
19556
  usePreventOverscroll();
19459
19557
  const { showThinking, scrollButtonState, scrollToTopPending } = useChatStore();
19460
19558
  const { attachments, handleAttachmentsChange, clearAttachments } = useAttachments();
19461
- const { scrollToBottom, scrollToLastUserMessage, pinLastUserMessage, scrollToProactive } = useAutoScroll({
19559
+ const { scrollToBottom, scrollToLastUserMessage, pinLastUserMessage, scrollToProactive, aterrizarAlFinal } = useAutoScroll({
19462
19560
  messagesEndRef,
19463
19561
  messagesContainerRef
19464
19562
  });
@@ -19486,11 +19584,23 @@ const Chat = ({
19486
19584
  }
19487
19585
  }, [scrollToTopPending, scrollToLastUserMessage]);
19488
19586
  useEffect(() => {
19587
+ if (messages[messages.length - 1]?.proactive) return;
19489
19588
  const spacer = messagesEndRef.current;
19490
19589
  if (spacer && spacer.style.minHeight && spacer.style.minHeight !== "0px") {
19491
19590
  pinLastUserMessage();
19492
19591
  }
19493
19592
  }, [messages]);
19593
+ const yaAterrizo = useRef(false);
19594
+ useEffect(() => {
19595
+ if (messages.length === 0) {
19596
+ yaAterrizo.current = false;
19597
+ return;
19598
+ }
19599
+ if (yaAterrizo.current) return;
19600
+ yaAterrizo.current = true;
19601
+ if (messages[messages.length - 1]?.proactive) return;
19602
+ aterrizarAlFinal();
19603
+ }, [messages, aterrizarAlFinal]);
19494
19604
  const lastProactiveIdRef = useRef(void 0);
19495
19605
  useEffect(() => {
19496
19606
  const last = messages[messages.length - 1];
@@ -19608,7 +19718,7 @@ const Chat = ({
19608
19718
  {
19609
19719
  className: `${COMPONENT_PREFIX$1}chat__scroll-to-bottom`,
19610
19720
  variant: "circle",
19611
- onClick: scrollToBottom,
19721
+ onClick: () => scrollToBottom(),
19612
19722
  "aria-label": "Ir al final",
19613
19723
  icon: /* @__PURE__ */ jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
19614
19724
  /* @__PURE__ */ jsx("path", { d: "M12 8v8", strokeWidth: "2", strokeLinecap: "round" }),
package/dist/index.js CHANGED
@@ -10945,6 +10945,7 @@ const Drawer = ({
10945
10945
  "data-testid": dataTestId,
10946
10946
  scrollIndicatorOffset = 1
10947
10947
  }) => {
10948
+ const idTitulo = React.useId();
10948
10949
  const [isMobile, setIsMobile] = React.useState(false);
10949
10950
  const contentRef = React.useRef(null);
10950
10951
  const [showScrollIndicator, setShowScrollIndicator] = React.useState(false);
@@ -11068,14 +11069,14 @@ const Drawer = ({
11068
11069
  style: getDrawerStyles(),
11069
11070
  role: "dialog",
11070
11071
  "aria-modal": "true",
11071
- "aria-labelledby": title ? "drawer-title" : void 0,
11072
+ "aria-labelledby": title ? idTitulo : void 0,
11072
11073
  "data-testid": dataTestId,
11073
11074
  children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `${COMPONENT_PREFIX}drawer__viewport`, children: [
11074
11075
  (title || showCloseButton || headerExtra) && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `${COMPONENT_PREFIX}drawer__header`, children: [
11075
11076
  title && /* @__PURE__ */ jsxRuntime.jsx(
11076
11077
  "h2",
11077
11078
  {
11078
- id: "drawer-title",
11079
+ id: idTitulo,
11079
11080
  className: `${COMPONENT_PREFIX}drawer__title`,
11080
11081
  children: title
11081
11082
  }
@@ -13188,7 +13189,9 @@ function mapFieldsToFormFields(fields, mode) {
13188
13189
  readOnly: field.readOnly,
13189
13190
  placeholder: field.placeholder,
13190
13191
  uiProps: field.uiProps,
13191
- transformOnSubmit: field.parseValue
13192
+ transformOnSubmit: field.parseValue,
13193
+ gridSpan: field.gridSpan
13194
+ // SPEC-131 T7
13192
13195
  }));
13193
13196
  }
13194
13197
 
@@ -13368,6 +13371,47 @@ function calculateTotalPages(totalRecords, limit) {
13368
13371
  return Math.ceil(totalRecords / limit);
13369
13372
  }
13370
13373
 
13374
+ function etiquetaDeOpcion(valor, uiProps) {
13375
+ const opciones = uiProps?.options;
13376
+ if (!Array.isArray(opciones)) return null;
13377
+ const encontrada = opciones.find((o) => o && String(o.value) === String(valor));
13378
+ return encontrada?.label != null ? String(encontrada.label) : null;
13379
+ }
13380
+ function esNumerico(moleculeType) {
13381
+ return moleculeType === "INPUT_CURRENCY" || moleculeType === "INPUT_NUMBER";
13382
+ }
13383
+ function aTexto(valor, uiProps, moleculeType) {
13384
+ if (valor == null || valor === "") return "";
13385
+ if (typeof valor === "boolean") return valor ? "S\xED" : "No";
13386
+ if (esNumerico(moleculeType)) {
13387
+ const n = Number(String(valor).replace(/[^\d.-]/g, ""));
13388
+ if (Number.isFinite(n)) return n.toLocaleString();
13389
+ }
13390
+ if (typeof File !== "undefined" && valor instanceof File) return valor.name;
13391
+ if (Array.isArray(valor)) {
13392
+ const partes = valor.map((v) => aTexto(v, uiProps, moleculeType)).filter(Boolean);
13393
+ return partes.join(", ");
13394
+ }
13395
+ if (typeof valor === "object") {
13396
+ const o = valor;
13397
+ for (const clave of ["label", "address", "name", "text", "value"]) {
13398
+ if (typeof o[clave] === "string" && o[clave]) return o[clave];
13399
+ }
13400
+ return "";
13401
+ }
13402
+ return etiquetaDeOpcion(valor, uiProps) ?? String(valor);
13403
+ }
13404
+ function resumenParaConfirmar(campos, datos) {
13405
+ if (!datos) return [];
13406
+ const lineas = [];
13407
+ for (const campo of campos) {
13408
+ const valor = aTexto(datos[campo.name], campo.uiProps, campo.moleculeType);
13409
+ if (!valor) continue;
13410
+ lineas.push({ label: campo.label ?? campo.name, valor });
13411
+ }
13412
+ return lineas;
13413
+ }
13414
+
13371
13415
  const useAnchoPropio = () => {
13372
13416
  const ref = React.useRef(null);
13373
13417
  const [ancho, setAncho] = React.useState(null);
@@ -13812,6 +13856,10 @@ function Crud({
13812
13856
  return "Formulario";
13813
13857
  }
13814
13858
  };
13859
+ const resumenConfirmacion = React.useMemo(
13860
+ () => confirmAction === "create" || confirmAction === "edit" ? resumenParaConfirmar(formFields, pendingFormData) : [],
13861
+ [confirmAction, formFields, pendingFormData]
13862
+ );
13815
13863
  const getConfirmMessage = () => {
13816
13864
  switch (confirmAction) {
13817
13865
  case "create":
@@ -13951,6 +13999,10 @@ function Crud({
13951
13999
  className: `${COMPONENT_PREFIX}crud__confirm-drawer`,
13952
14000
  children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `${COMPONENT_PREFIX}crud__confirm-content`, children: [
13953
14001
  /* @__PURE__ */ jsxRuntime.jsx("p", { className: `${COMPONENT_PREFIX}crud__confirm-message`, children: getConfirmMessage() }),
14002
+ resumenConfirmacion.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("dl", { className: `${COMPONENT_PREFIX}crud__confirm-summary`, children: resumenConfirmacion.map((linea) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `${COMPONENT_PREFIX}crud__confirm-summary-row`, children: [
14003
+ /* @__PURE__ */ jsxRuntime.jsx("dt", { children: linea.label }),
14004
+ /* @__PURE__ */ jsxRuntime.jsx("dd", { children: linea.valor })
14005
+ ] }, linea.label)) }),
13954
14006
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `${COMPONENT_PREFIX}crud__confirm-actions`, children: [
13955
14007
  /* @__PURE__ */ jsxRuntime.jsx(
13956
14008
  Button,
@@ -15896,7 +15948,8 @@ function usePlaceSuggestions(query, enabled) {
15896
15948
  return {
15897
15949
  lat: typeof loc.lat === "function" ? loc.lat() : loc.lat,
15898
15950
  lng: typeof loc.lng === "function" ? loc.lng() : loc.lng,
15899
- address: String(place.formattedAddress ?? "")
15951
+ address: limpiarDireccion(String(place.formattedAddress ?? ""))
15952
+ // SPEC-131 T6
15900
15953
  };
15901
15954
  } catch {
15902
15955
  return null;
@@ -15906,13 +15959,27 @@ function usePlaceSuggestions(query, enabled) {
15906
15959
  );
15907
15960
  return { suggestions, resolve, clear: () => setSuggestions([]) };
15908
15961
  }
15962
+ function limpiarDireccion(address) {
15963
+ const normal = (s) => s.trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
15964
+ const vistos = /* @__PURE__ */ new Set();
15965
+ const tramos = [];
15966
+ for (const tramo of address.split(",")) {
15967
+ const limpio = tramo.trim();
15968
+ if (!limpio) continue;
15969
+ const clave = normal(limpio);
15970
+ if (vistos.has(clave)) continue;
15971
+ vistos.add(clave);
15972
+ tramos.push(limpio);
15973
+ }
15974
+ return tramos.join(", ");
15975
+ }
15909
15976
  function reverseGeocode(lat, lng) {
15910
15977
  return new Promise((resolve) => {
15911
15978
  const maps = window.google?.maps;
15912
15979
  if (!maps?.Geocoder) return resolve(null);
15913
15980
  new maps.Geocoder().geocode({ location: { lat, lng } }, (results, status) => {
15914
15981
  if (status !== "OK" || !results?.length) return resolve(null);
15915
- resolve(String(results[0].formatted_address ?? "") || null);
15982
+ resolve(limpiarDireccion(String(results[0].formatted_address ?? "")) || null);
15916
15983
  });
15917
15984
  });
15918
15985
  }
@@ -16069,6 +16136,10 @@ function toRenderField(f, loaded, eventBus, mapsApiKey, loadError) {
16069
16136
  showInModes: f.showInModes,
16070
16137
  required: f.required,
16071
16138
  placeholder: f.placeholder,
16139
+ // SPEC-131 T7 — la fila entera: al elegir la dirección se despliega el mapa de verificación
16140
+ // y, a media fila, su campo hermano se quedaba con ~400px de blanco al lado. Medido entre
16141
+ // «¿En qué situación está?» y «¿A dónde lo llevamos?» del formulario de solicitud.
16142
+ gridSpan: 2,
16072
16143
  uiProps: {
16073
16144
  // SPEC-074 — `error` viene del render de CUSTOM (DynamicForm ya lo pasaba) y aquí se
16074
16145
  // desechaba: el campo obligatorio más importante era el único mudo.
@@ -16693,6 +16764,8 @@ const ServicesMap = ({ config, eventBus, apiKey, filter, fill }) => {
16693
16764
  const { service, url, method, payloadMap } = config.data.list;
16694
16765
  void fetchViaEventBus(eventBus, service, url, method ?? "POST", {
16695
16766
  ...payloadMap,
16767
+ // SPEC-131 T6 — el del config primero, el de la instancia después: quien acota manda.
16768
+ ...config.defaultFilter ?? {},
16696
16769
  ...filter ?? {}
16697
16770
  }).then((res) => {
16698
16771
  if (!alive) return;
@@ -18846,6 +18919,10 @@ const useInputFocus = ({
18846
18919
  };
18847
18920
  };
18848
18921
 
18922
+ function reservaNecesaria(clientHeight, scrollHeight, targetTop) {
18923
+ const debajoDelObjetivo = scrollHeight - targetTop;
18924
+ return Math.max(0, clientHeight - debajoDelObjetivo);
18925
+ }
18849
18926
  const useAutoScroll = ({
18850
18927
  messagesEndRef,
18851
18928
  messagesContainerRef
@@ -18864,11 +18941,12 @@ const useAutoScroll = ({
18864
18941
  const container = messagesContainerRef.current;
18865
18942
  const spacer = messagesEndRef.current;
18866
18943
  if (!container || !spacer || !target) return;
18867
- spacer.style.minHeight = `${container.clientHeight}px`;
18944
+ spacer.style.minHeight = "0px";
18868
18945
  const containerRect = container.getBoundingClientRect();
18869
18946
  const msgRect = target.getBoundingClientRect();
18870
18947
  const padTop = parseFloat(getComputedStyle(container).paddingTop) || 0;
18871
18948
  const targetTop = msgRect.top - containerRect.top + container.scrollTop - padTop;
18949
+ spacer.style.minHeight = `${reservaNecesaria(container.clientHeight, container.scrollHeight, targetTop)}px`;
18872
18950
  container.scrollTo({ top: targetTop, behavior: "smooth" });
18873
18951
  }, [messagesContainerRef, messagesEndRef]);
18874
18952
  const last = React.useCallback((selector) => {
@@ -18910,15 +18988,33 @@ const useAutoScroll = ({
18910
18988
  });
18911
18989
  }, 150);
18912
18990
  }, [doScroll]);
18913
- const scrollToBottom = React.useCallback(() => {
18991
+ const aterrizarAlFinal = React.useCallback(() => {
18992
+ const container = messagesContainerRef.current;
18993
+ if (!container) return;
18994
+ resetSpacer();
18995
+ const hasta = Date.now() + 1e3;
18996
+ let ultimoAlto = -1;
18997
+ const pegar = () => {
18998
+ const c = messagesContainerRef.current;
18999
+ if (!c) return;
19000
+ if (c.scrollHeight !== ultimoAlto) {
19001
+ ultimoAlto = c.scrollHeight;
19002
+ c.scrollTop = c.scrollHeight;
19003
+ }
19004
+ if (Date.now() < hasta) requestAnimationFrame(pegar);
19005
+ };
19006
+ requestAnimationFrame(pegar);
19007
+ }, [messagesContainerRef, resetSpacer]);
19008
+ const scrollToBottom = React.useCallback((suave = true) => {
18914
19009
  cancelPending();
18915
19010
  resetSpacer();
18916
19011
  requestAnimationFrame(() => {
19012
+ const comportamiento = suave ? "smooth" : "auto";
18917
19013
  const container = messagesContainerRef.current;
18918
19014
  if (container) {
18919
- container.scrollTo({ top: container.scrollHeight, behavior: "smooth" });
19015
+ container.scrollTo({ top: container.scrollHeight, behavior: comportamiento });
18920
19016
  } else {
18921
- messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
19017
+ messagesEndRef.current?.scrollIntoView({ behavior: comportamiento, block: "end" });
18922
19018
  }
18923
19019
  });
18924
19020
  }, [cancelPending, resetSpacer, messagesContainerRef, messagesEndRef]);
@@ -18926,7 +19022,9 @@ const useAutoScroll = ({
18926
19022
  scrollToLastUserMessage,
18927
19023
  pinLastUserMessage,
18928
19024
  scrollToProactive,
18929
- scrollToBottom
19025
+ scrollToBottom,
19026
+ aterrizarAlFinal
19027
+ // SPEC-131 T3
18930
19028
  };
18931
19029
  };
18932
19030
 
@@ -19479,7 +19577,7 @@ const Chat = ({
19479
19577
  usePreventOverscroll();
19480
19578
  const { showThinking, scrollButtonState, scrollToTopPending } = useChatStore();
19481
19579
  const { attachments, handleAttachmentsChange, clearAttachments } = useAttachments();
19482
- const { scrollToBottom, scrollToLastUserMessage, pinLastUserMessage, scrollToProactive } = useAutoScroll({
19580
+ const { scrollToBottom, scrollToLastUserMessage, pinLastUserMessage, scrollToProactive, aterrizarAlFinal } = useAutoScroll({
19483
19581
  messagesEndRef,
19484
19582
  messagesContainerRef
19485
19583
  });
@@ -19507,11 +19605,23 @@ const Chat = ({
19507
19605
  }
19508
19606
  }, [scrollToTopPending, scrollToLastUserMessage]);
19509
19607
  React.useEffect(() => {
19608
+ if (messages[messages.length - 1]?.proactive) return;
19510
19609
  const spacer = messagesEndRef.current;
19511
19610
  if (spacer && spacer.style.minHeight && spacer.style.minHeight !== "0px") {
19512
19611
  pinLastUserMessage();
19513
19612
  }
19514
19613
  }, [messages]);
19614
+ const yaAterrizo = React.useRef(false);
19615
+ React.useEffect(() => {
19616
+ if (messages.length === 0) {
19617
+ yaAterrizo.current = false;
19618
+ return;
19619
+ }
19620
+ if (yaAterrizo.current) return;
19621
+ yaAterrizo.current = true;
19622
+ if (messages[messages.length - 1]?.proactive) return;
19623
+ aterrizarAlFinal();
19624
+ }, [messages, aterrizarAlFinal]);
19515
19625
  const lastProactiveIdRef = React.useRef(void 0);
19516
19626
  React.useEffect(() => {
19517
19627
  const last = messages[messages.length - 1];
@@ -19629,7 +19739,7 @@ const Chat = ({
19629
19739
  {
19630
19740
  className: `${COMPONENT_PREFIX$1}chat__scroll-to-bottom`,
19631
19741
  variant: "circle",
19632
- onClick: scrollToBottom,
19742
+ onClick: () => scrollToBottom(),
19633
19743
  "aria-label": "Ir al final",
19634
19744
  icon: /* @__PURE__ */ jsxRuntime.jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
19635
19745
  /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 8v8", strokeWidth: "2", strokeLinecap: "round" }),