This commit is contained in:
valenti 2026-06-02 10:46:41 -04:00
parent 44a671338a
commit 0f630560c2
2 changed files with 462 additions and 88 deletions

View File

@ -46,9 +46,40 @@ interface OwnRow {
fuente: string;
}
// ── layout del mapa conceptual ──────────────────────────────────────────────
const NODE_W = 215;
const NODE_H = 88;
const COL_GAP = 72;
const ROW_GAP = 26;
const PAD = 34;
const wrapName = (name: string, maxChars = 30, maxLines = 2): string[] => {
const words = (name || 'Sin nombre').trim().split(/\s+/)
.map(w => (w.length > maxChars ? w.slice(0, maxChars - 1) + '\u2026' : w));
const lines: string[] = [];
let cur = '';
for (const w of words) {
const candidate = cur ? `${cur} ${w}` : w;
if (candidate.length <= maxChars) cur = candidate;
else {
if (cur) lines.push(cur);
cur = w;
if (lines.length === maxLines) break;
}
}
if (cur && lines.length < maxLines) lines.push(cur);
const consumed = lines.join(' ').length;
if (lines.length === maxLines && (name || '').trim().length > consumed) {
const last = lines[maxLines - 1];
lines[maxLines - 1] = (last.length > maxChars - 1 ? last.slice(0, maxChars - 1) : last) + '\u2026';
}
return lines.length ? lines : ['Sin nombre'];
};
const MeshTable = ({ title, height, dequienesRelationships, mainRut, forceExpanded = false }: any) => {
const [showOwn, setShowOwn] = useState(true);
const [expandedOwn, setExpandedOwn] = useState<Set<number>>(new Set());
const [viewMode, setViewMode] = useState<'table' | 'map'>('table');
if (!dequienesRelationships) return null;
@ -189,9 +220,6 @@ const MeshTable = ({ title, height, dequienesRelationships, mainRut, forceExpand
});
})();
const containerStyle =
height !== 'auto' ? { maxHeight: height, overflowY: 'auto' as const } : {};
const thStyle: React.CSSProperties = {
border: '1px solid #e5e7eb', padding: '6px 8px', textAlign: 'left',
fontSize: '12px', fontWeight: 600, color: '#374151',
@ -292,79 +320,399 @@ const MeshTable = ({ title, height, dequienesRelationships, mainRut, forceExpand
.filter(r => r.parentId === null)
.sort((a, b) => (b.percentage ?? -1) - (a.percentage ?? -1));
// ── Mapa conceptual ───────────────────────────────────────────────────────
// Usa exactamente los mismos ownRows que la tabla (dueños hacia arriba).
// Layout izquierda→derecha: N0 = RUT principal | N1 = dueños | N2..N5 = dueños-de-dueños.
const buildMapLayout = () => {
if (!ownRows.length) return null;
const slot = NODE_H + ROW_GAP;
const cyMap = new Map<number, number>();
let rowCursor = 0;
const place = (rowId: number) => {
const row = ownRows.find(r => r.id === rowId);
if (!row) return;
const kids = ownRows.filter(r => r.parentId === rowId);
const isExp = forceExpanded || expandedOwn.has(rowId);
const visibleKids = isExp ? kids : [];
if (!visibleKids.length) {
cyMap.set(rowId, rowCursor * slot + NODE_H / 2);
rowCursor++;
} else {
visibleKids.forEach(k => place(k.id));
const fy = cyMap.get(visibleKids[0].id)!;
const ly = cyMap.get(visibleKids[visibleKids.length - 1].id)!;
cyMap.set(rowId, (fy + ly) / 2);
}
};
rootOwnRows.forEach(r => place(r.id));
let rootCy: number;
if (rootOwnRows.length) {
const fy = cyMap.get(rootOwnRows[0].id)!;
const ly = cyMap.get(rootOwnRows[rootOwnRows.length - 1].id)!;
rootCy = (fy + ly) / 2;
} else {
rootCy = NODE_H / 2;
}
interface MapNode {
key: string;
level: number;
rut: string;
name: string;
percentage: number | null;
isCompany: boolean;
cx: number;
top: number;
isMain: boolean;
rowId: number | null;
hasChildren: boolean;
isExpanded: boolean;
}
interface MapEdge {
id: string;
x1: number; y1: number;
x2: number; y2: number;
pct: number | null;
}
const colStep = NODE_W + COL_GAP;
const nodes: MapNode[] = [];
const edges: MapEdge[] = [];
let minY = rootCy - NODE_H / 2;
let maxY = rootCy + NODE_H / 2;
let maxLvl = 0;
const visitedIds = new Set<number>();
const walk = (rowId: number) => {
if (visitedIds.has(rowId)) return;
visitedIds.add(rowId);
const row = ownRows.find(r => r.id === rowId);
if (!row) return;
const cy = cyMap.get(rowId);
if (cy == null) return;
minY = Math.min(minY, cy - NODE_H / 2);
maxY = Math.max(maxY, cy + NODE_H / 2);
maxLvl = Math.max(maxLvl, row.level);
const kids = ownRows.filter(r => r.parentId === rowId);
const isExp = forceExpanded || expandedOwn.has(rowId);
nodes.push({
key: `n${rowId}`,
level: row.level,
rut: row.targetRut,
name: row.targetName,
percentage: row.percentage,
isCompany: isSociedad(row.targetRut),
cx: row.level * colStep + NODE_W / 2 + PAD,
top: cy,
isMain: false,
rowId,
hasChildren: kids.length > 0,
isExpanded: isExp,
});
if (isExp) kids.forEach(k => walk(k.id));
};
rootOwnRows.forEach(r => walk(r.id));
const rootNode: MapNode = {
key: 'root',
level: 0,
rut: cleanMain,
name: getName(cleanMain),
percentage: null,
isCompany: isSociedad(cleanMain),
cx: NODE_W / 2 + PAD,
top: rootCy,
isMain: true,
rowId: null,
hasChildren: rootOwnRows.length > 0,
isExpanded: true,
};
// Normalizo Y de center -> top con padding
rootNode.top = rootNode.top - NODE_H / 2 - minY + PAD;
nodes.forEach(n => { n.top = n.top - NODE_H / 2 - minY + PAD; });
const allNodes = [rootNode, ...nodes];
const byKey = new Map(allNodes.map(n => [n.key, n]));
const rowIdToKey = new Map<number, string>();
nodes.forEach(n => { if (n.rowId != null) rowIdToKey.set(n.rowId, n.key); });
// root -> N1
rootOwnRows.forEach(r => {
const key = rowIdToKey.get(r.id);
if (!key) return;
const child = byKey.get(key)!;
edges.push({
id: `root->${key}`,
x1: rootNode.cx + NODE_W / 2,
y1: rootNode.top + NODE_H / 2,
x2: child.cx - NODE_W / 2,
y2: child.top + NODE_H / 2,
pct: r.percentage,
});
});
// padre expandido -> hijos
nodes.forEach(parentNode => {
if (parentNode.rowId == null || !parentNode.isExpanded) return;
const kids = ownRows.filter(r => r.parentId === parentNode.rowId);
kids.forEach(k => {
const key = rowIdToKey.get(k.id);
if (!key) return;
const child = byKey.get(key)!;
edges.push({
id: `${parentNode.key}->${key}`,
x1: parentNode.cx + NODE_W / 2,
y1: parentNode.top + NODE_H / 2,
x2: child.cx - NODE_W / 2,
y2: child.top + NODE_H / 2,
pct: k.percentage,
});
});
});
const width = maxLvl * colStep + NODE_W + PAD * 2;
const fullHeight = maxY - minY + PAD * 2;
return { allNodes, edges, width, fullHeight, maxLvl };
};
const expandAll = () => {
const all = new Set<number>();
ownRows.forEach(r => {
if (ownRows.some(c => c.parentId === r.id)) all.add(r.id);
});
setExpandedOwn(all);
};
const collapseAll = () => setExpandedOwn(new Set());
const renderMap = () => {
const layout = buildMapLayout();
if (!layout) {
return (
<p className="text-gray-500 dark:text-gray-400 italic text-sm py-4">
Sin datos de malla societaria
</p>
);
}
const renderNode = (n: any) => {
let bg: string, border: string, badgeBg: string, badgeTx: string, sub: string, nameCol: string;
if (n.isMain) {
bg = '#2563eb'; border = '#1d4ed8'; badgeBg = '#ffffff';
badgeTx = '#1d4ed8'; sub = '#bfdbfe'; nameCol = '#ffffff';
} else if (n.isCompany) {
bg = '#ffffff'; border = '#60a5fa'; badgeBg = '#dbeafe';
badgeTx = '#1d4ed8'; sub = '#6b7280'; nameCol = '#111827';
} else {
bg = '#fefce8'; border = '#facc15'; badgeBg = '#dbeafe';
badgeTx = '#1d4ed8'; sub = '#6b7280'; nameCol = '#111827';
}
const nameLines = wrapName(n.name);
const left = n.cx - NODE_W / 2;
const showToggle = n.hasChildren && !forceExpanded && !n.isMain && n.rowId != null;
return (
<g key={n.key} transform={`translate(${left}, ${n.top})`}>
<rect width={NODE_W} height={NODE_H} rx={10} fill={bg} stroke={border} strokeWidth={2} />
<rect x={12} y={11} width={26} height={15} rx={4} fill={badgeBg} />
<text x={25} y={22} textAnchor="middle" fontSize={10} fontWeight={700} fill={badgeTx}>N{n.level}</text>
<text x={45} y={22} fontSize={10} fill={sub}>{n.isCompany ? 'Empresa' : 'Persona'}</text>
<text x={13} y={42} fontSize={11} fill={sub} fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace">
{formatRut(n.rut)}
</text>
{nameLines.map((ln: string, i: number) => (
<text key={i} x={13} y={58 + i * 14} fontSize={12} fontWeight={600} fill={nameCol}>{ln}</text>
))}
{!n.isMain && n.percentage != null && (
<text x={13} y={84} fontSize={10} fontWeight={600} fill="#2563eb">
{n.percentage}% participación
</text>
)}
{showToggle && (
<g
transform={`translate(${NODE_W}, ${NODE_H / 2})`}
style={{ cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); toggleOwn(n.rowId); }}
>
<circle r={11} fill={n.isExpanded ? '#ef4444' : '#16a34a'} stroke="#ffffff" strokeWidth={2} />
<text textAnchor="middle" y={4} fontSize={15} fontWeight={700} fill="#ffffff">
{n.isExpanded ? '\u2212' : '+'}
</text>
</g>
)}
</g>
);
};
return (
<>
{!forceExpanded && (
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 mb-3 flex flex-wrap items-center gap-3 text-sm">
<span>
<span className="text-gray-600 dark:text-gray-400">Nivel: </span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{layout.maxLvl} / {maxLevel}
</span>
</span>
<div className="ml-auto flex gap-2">
<button
onClick={expandAll}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors"
>
Expandir todo
</button>
<button
onClick={collapseAll}
className="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 text-xs font-medium rounded transition-colors"
>
Colapsar todo
</button>
</div>
</div>
)}
<div
className="border border-gray-300 dark:border-gray-600 rounded-lg"
style={{ background: '#ffffff', overflow: 'auto' }}
>
<svg
width={layout.width}
height={layout.fullHeight}
viewBox={`0 0 ${layout.width} ${layout.fullHeight}`}
xmlns="http://www.w3.org/2000/svg"
style={{ display: 'block', fontFamily: 'ui-sans-serif, system-ui, sans-serif' }}
>
<rect x={0} y={0} width={layout.width} height={layout.fullHeight} fill="#ffffff" />
{layout.edges.map((e: any) => {
const midX = (e.x1 + e.x2) / 2;
return (
<g key={e.id}>
<path
d={`M ${e.x1},${e.y1} C ${midX},${e.y1} ${midX},${e.y2} ${e.x2},${e.y2}`}
fill="none" stroke="#94a3b8" strokeWidth={1.6}
/>
{e.pct != null && (
<g transform={`translate(${midX}, ${(e.y1 + e.y2) / 2})`}>
<rect x={-21} y={-9} width={42} height={18} rx={4} fill="#eff6ff" stroke="#bfdbfe" />
<text textAnchor="middle" y={4} fontSize={10} fontWeight={700} fill="#1d4ed8">
{e.pct}%
</text>
</g>
)}
</g>
);
})}
{layout.allNodes.map(renderNode)}
</svg>
</div>
{!forceExpanded && (
<p className="text-xs text-gray-400 mt-1.5">
Usá el botón <span className="font-semibold text-green-600">+</span> al costado de cada nodo para expandir sus dueños.
Hasta {MAX_DEPTH} niveles de profundidad.
</p>
)}
</>
);
};
return (
<div className="mt-4 mb-4" data-pdf="mesh-table">
<h3 className="text-lg font-bold dark:text-white mb-4">{title}</h3>
<div style={containerStyle} className="space-y-6">
{ownRows.length > 0 ? (
<div>
<SectionToggle
label="Controladores y Estructura de Propiedad"
open={showOwn}
onToggle={() => setShowOwn(v => !v)}
/>
{showOwn && (
<>
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 mb-3 space-y-1.5 text-sm">
{controlador && (
<div>
<span className="text-gray-600 dark:text-gray-400">Controlador principal identificado: </span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{controlador.targetRut} {controlador.targetName}
</span>
{controlador.level > 0 && (
<span className="text-gray-500 dark:text-gray-400 ml-2">
(Nivel {controlador.level}{controlador.percentage != null ? ` - ${controlador.percentage}%` : ''})
</span>
)}
</div>
)}
<div>
<span className="text-gray-600 dark:text-gray-400">Cobertura de la malla: </span>
<span className="text-gray-900 dark:text-gray-100">
Nivel máximo alcanzado: <strong>{maxLevel} de {MAX_DEPTH}</strong>
</span>
</div>
</div>
<div className="overflow-x-auto">
<table data-pdf-table="own" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', fontSize: '13px' }}>
<colgroup>
<col style={{ width: '70px' }} />
<col style={{ width: '30%' }} />
<col style={{ width: '30%' }} />
<col style={{ width: '110px' }} />
<col style={{ width: '90px' }} />
<col style={{ width: '75px' }} />
</colgroup>
<thead>
<tr style={{ backgroundColor: '#f3f4f6' }}>
<th style={thStyle}>Nivel</th>
<th style={thStyle}>Empresa</th>
<th style={thStyle}>Dueño</th>
<th style={thStyle}>%</th>
<th style={thStyle}>Tipo</th>
<th style={thStyle}>Fuente</th>
</tr>
</thead>
<tbody>
{renderOwnRows(rootOwnRows, { count: 0 })}
</tbody>
</table>
<p className="text-xs text-gray-400 mt-1.5">
Nota: Hasta {MAX_DEPTH} niveles · Solo se expanden dueños que son sociedades ·
% según DO / DOH / SII (puede haber múltiples fuentes y duplicados).
</p>
</div>
</>
)}
</div>
) : (
<p className="text-gray-500 dark:text-gray-400 italic text-sm py-4">Sin datos de malla societaria</p>
)}
<div className="flex items-center justify-between mb-4 gap-3 flex-wrap">
<h3 className="text-lg font-bold dark:text-white">{title}</h3>
{/* {ownRows.length > 0 && !forceExpanded && (
<button
onClick={() => setViewMode(v => v === 'table' ? 'map' : 'table')}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors"
>
{viewMode === 'table' ? 'Ver mapa conceptual' : 'Ver tabla'}
</button>
)} */}
</div>
{viewMode === 'map' && ownRows.length > 0 ? (
renderMap()
) : (
<div className="space-y-6">
{ownRows.length > 0 ? (
<div>
<SectionToggle
label="Controladores y Estructura de Propiedad"
open={showOwn}
onToggle={() => setShowOwn(v => !v)}
/>
{showOwn && (
<>
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 mb-3 space-y-1.5 text-sm">
{controlador && (
<div>
<span className="text-gray-600 dark:text-gray-400">Controlador principal identificado: </span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{controlador.targetRut} {controlador.targetName}
</span>
{controlador.level > 0 && (
<span className="text-gray-500 dark:text-gray-400 ml-2">
(Nivel {controlador.level}{controlador.percentage != null ? ` - ${controlador.percentage}%` : ''})
</span>
)}
</div>
)}
<div>
<span className="text-gray-600 dark:text-gray-400">Cobertura de la malla: </span>
<span className="text-gray-900 dark:text-gray-100">
Nivel máximo alcanzado: <strong>{maxLevel} de {MAX_DEPTH}</strong>
</span>
</div>
</div>
<div className="overflow-x-auto">
<table data-pdf-table="own" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', fontSize: '13px' }}>
<colgroup>
<col style={{ width: '70px' }} />
<col style={{ width: '30%' }} />
<col style={{ width: '30%' }} />
<col style={{ width: '110px' }} />
<col style={{ width: '90px' }} />
<col style={{ width: '75px' }} />
</colgroup>
<thead>
<tr style={{ backgroundColor: '#f3f4f6' }}>
<th style={thStyle}>Nivel</th>
<th style={thStyle}>Empresa</th>
<th style={thStyle}>Dueño</th>
<th style={thStyle}>%</th>
<th style={thStyle}>Tipo</th>
<th style={thStyle}>Fuente</th>
</tr>
</thead>
<tbody>
{renderOwnRows(rootOwnRows, { count: 0 })}
</tbody>
</table>
<p className="text-xs text-gray-400 mt-1.5">
Nota: Hasta {MAX_DEPTH} niveles · Solo se expanden dueños que son sociedades ·
% según DO / DOH / SII (puede haber múltiples fuentes y duplicados).
</p>
</div>
</>
)}
</div>
) : (
<p className="text-gray-500 dark:text-gray-400 italic text-sm py-4">Sin datos de malla societaria</p>
)}
</div>
)}
</div>
);
};

View File

@ -21,7 +21,7 @@ import { Dialog } from '@headlessui/react';
import {
Tooltip,
} from '@mui/material';
/* import MeshFlowChart from '../components/ui/MeshFlowChart/MeshFlowChart'; */
import { ChevronDown, ChevronUp, Download, Edit3, Save, X, FileText } from 'lucide-react';
import * as XLSX from 'xlsx';
import { useTranslation } from 'react-i18next';
@ -337,6 +337,14 @@ const FastCheck: React.FC = () => {
unpaidAmount: 'Monto',
documentType: 'Tipo de Deuda',
totalUTA: 'Multa UTA',
libradorName: 'Librador',
localidadName: 'Localidad',
justificationDescription: 'Justificación',
boletinNumber: 'N° Boletín',
boletinPage: 'Página Boletín',
};
const [count, setCount] = useState<any>({
@ -348,7 +356,7 @@ const FastCheck: React.FC = () => {
const columnasPermitidas: Record<string, string[]> = {
'Multas Laborales': ['motivo', 'Motivo', 'boletin', 'Boletín', 'fecha', 'Fecha', 'tipoInfraccion', 'Tipo Infracción', 'infraccion', 'Infracción', 'monto', 'Monto', 'resolucion', 'Resolución'],
'Protestos y Morosidades': ['vencimiento', 'Vencimiento', 'monto', 'Monto', 'tipoDeuda', 'Tipo de Deuda', 'publicacion', 'Publicación', 'expirationDate', 'unpaidAmount', 'documentType',],
'Protestos y Morosidades': ['vencimiento', 'Vencimiento', 'monto', 'Monto', 'tipoDeuda', 'Tipo de Deuda', 'publicacion', 'Publicación', 'expirationDate', 'unpaidAmount', 'documentType', 'libradorName', 'localidadName', 'justificationDescription', 'boletinNumber', 'boletinPage'],
'Condenas por Prácticas Antisindicales': ['rit', 'RIT', 'tribunal', 'Tribunal', 'fechaEjecutoria', 'Fecha ejecutoria', 'hechosCondenados', 'Hechos condenados', 'tipoDenuncia', 'Tipo de Denuncia', 'montoMulta', 'Monto Multa'],
'Proceso Sanciones Medioambientales': ['unidadFiscalizable', 'expediente', 'procesoSancionTipoNombre', 'Estado', 'fechaInicio', 'fechaTermino', 'confirmaPdC', 'multaTotalUTA', 'linkSNIFA', 'regionNombre', 'comunaNombre', 'linkSNIFA_UF', 'fechaActualizacion'],
'Sanciones Medioambientales': ['Unidad Fiscalizable', 'Expediente', 'Tipo Sanción', 'Estado', 'Fecha', 'totalUTA', 'Región',],
@ -385,12 +393,7 @@ const FastCheck: React.FC = () => {
data?.details?.sheriffLogData?.filteredDetails?.compliance?.penal?.coincidencias ||
data?.logEntry?.filteredDetails?.compliance?.penal?.coincidencias ||
[];
/*
console.warn(
'[FastCheckEX] compliance.penal.coincidencias',
data?.sheriffLogData?.filteredDetails?.compliance?.penal?.coincidencias
);
*/
const coincidenciasFiltradas = todasCoincidencias.filter((item: any) => {
const postura = item?.postura?.toLowerCase() || '';
return postura === 'demandado' || postura === 'querellado' || postura === 'denunciado';
@ -2305,6 +2308,21 @@ const FastCheck: React.FC = () => {
await new Promise(resolve => setTimeout(resolve, 100));
iframeDoc.body.appendChild(clonedElement);
await new Promise(resolve => setTimeout(resolve, 100));
clonedElement.querySelectorAll('svg').forEach((svg) => {
const r = svg.getBoundingClientRect();
if (r.width > 0 && r.height > 0) {
svg.setAttribute('width', String(Math.ceil(r.width)));
svg.setAttribute('height', String(Math.ceil(r.height)));
}
});
const canvas = await html2canvas(clonedElement, {
scale: 2,
useCORS: true,
@ -4180,8 +4198,13 @@ const FastCheck: React.FC = () => {
tipoDeuda: 'tipoDeuda',
documentType: 'tipoDeuda',
publicacion: 'publicacion',
libradorName: 'libradorName',
localidadName: 'localidadName',
justificationDescription: 'justificationDescription',
boletinNumber: 'boletinNumber',
boletinPage: 'boletinPage',
};
const colOrder = ['vencimiento', 'monto', 'tipoDeuda', 'publicacion'];
const colOrder = ['vencimiento', 'monto', 'tipoDeuda', 'publicacion', 'libradorName', 'localidadName', 'justificationDescription', 'boletinNumber', 'boletinPage'];
filteredDetails = details.map((row: any) => {
const filtered: Record<string, any> = {};
Object.keys(keyMap).forEach(srcKey => {
@ -4225,11 +4248,6 @@ const FastCheck: React.FC = () => {
<ChevronDown className="h-6 w-6 text-blue-500" />
}
</div>
{expandedSections.informacion && (
<div className="space-y-6">
{/* Informazioni generali */}
@ -4250,7 +4268,7 @@ const FastCheck: React.FC = () => {
</div>
<div>
<p className="font-bold dark:text-white">Tamaño Empresa:</p>
<p className="dark:text-gray-300">{companyGeneralInfo.tamanoEmpresa}</p>
<p className="dark:text-gray-300">{data?.sheriffLogData?.filteredDetails?.sheriffV2Data?.resumen?.data?.identificacion?.tamanoEmpresaSii?.tamanoEmpresa}</p>
</div>
<div>
<p className="font-bold dark:text-white">Rango Ventas UF:</p>
@ -4553,6 +4571,14 @@ const FastCheck: React.FC = () => {
</div>
)}
{/* {!exportingPdf && (
<MeshFlowChart
title="Mapa de Empresas en las que Participa"
dequienesRelationships={data?.sheriffLogData?.filteredDetails?.dequienesRelationships}
mainRut={companyData.rut}
height={460}
/>
)} */}
<MeshTable
title="Malla Relacional"
height={exportingPdf && "auto" /* : "35rem" */}