F5.1 · Inventario limpio, jerarquía manual y Hallazgos filtrados (#28)

Reset controlado de datos operativos e históricos, nueva jerarquía manual Departamento → Área → Yacimiento → Instalación → Subinstalación, filtros y administración inline de Hallazgos, y corrección de alta con Área sin Operadora.
This commit is contained in:
2026-09-09 08:55:01 -03:00
committed by GitHub
parent 3d83545033
commit 7338f277ad
16 changed files with 816 additions and 498 deletions
@@ -6,11 +6,13 @@ import { Icon } from '../../components/Icon';
import { getAssetLineage } from '../../lib/api';
import type { AssetLineageItem } from '../../lib/api';
import {
listInventoryAreas,
listInventoryCompanies,
listInventoryDepartments,
listInventoryChildren,
} from '../../lib/inventoryBrowserApi';
import type {
InventoryBrowserArea,
InventoryBrowserCompany,
InventoryBrowserDepartment,
InventoryBrowserItem,
InventoryQuery,
} from '../../lib/inventoryBrowserApi';
@@ -26,28 +28,30 @@ function navigationHref(base: URLSearchParams, parentId?: string) {
return `/inventarios${params.size ? `?${params}` : ''}`;
}
function AreaCard({ area, href }: { area: InventoryBrowserArea; href: string }) {
function DepartmentCard({ department, href }: { department: InventoryBrowserDepartment; href: string }) {
return <Link className="asset-browser-item" to={href}>
<span className="asset-browser-item-icon"><Icon name="map" size={17} /></span>
<span className="asset-browser-item-main">
<strong>{area.name}</strong>
<small>
{area.code} · {area.yacimientoCount} yacimiento{area.yacimientoCount === 1 ? '' : 's'}
{area.currentOperator ? ` · Operadora vigente: ${area.currentOperator.name}` : ' · Sin operadora vigente'}
</small>
</span>
<span className="asset-browser-item-status">
<strong>{area.inventoryCount}</strong>
<small>instancia{area.inventoryCount === 1 ? '' : 's'} real{area.inventoryCount === 1 ? '' : 'es'}</small>
<strong>{department.name}</strong>
<small>{department.code} · {department.areaCount} Área{department.areaCount === 1 ? '' : 's'}</small>
</span>
<span className="asset-browser-item-status"><span className={`status-badge ${assetStatusClass(department.informationStatus)}`}>{assetStatusLabel(department.informationStatus)}</span></span>
<Icon name="chevron" size={16} />
</Link>;
}
function CompanyCard({ company }: { company: InventoryBrowserCompany }) {
return <Link className="asset-browser-item" to={`/inventarios/${company.id}`}>
<span className="asset-browser-item-icon"><Icon name="users" size={17} /></span>
<span className="asset-browser-item-main"><strong>{company.name}</strong><small>{company.code} · {company.areaCount} Área{company.areaCount === 1 ? '' : 's'} operada{company.areaCount === 1 ? '' : 's'}</small></span>
<span className="asset-browser-item-status"><span className={`status-badge ${assetStatusClass(company.informationStatus)}`}>{assetStatusLabel(company.informationStatus)}</span></span>
<Icon name="chevron" size={16} />
</Link>;
}
function InventoryCard({ item, href }: { item: InventoryBrowserItem; href: string }) {
const structural = !item.isInventoryInstance;
return <Link className="asset-browser-item" to={href}>
<span className="asset-browser-item-icon"><Icon name={structural ? 'map' : 'layers'} size={17} /></span>
<span className="asset-browser-item-icon"><Icon name={['departamento','area','yacimiento'].includes(item.type.code.toLowerCase()) ? 'map' : 'layers'} size={17} /></span>
<span className="asset-browser-item-main">
<strong>{item.name}</strong>
<small>
@@ -57,9 +61,10 @@ function InventoryCard({ item, href }: { item: InventoryBrowserItem; href: strin
</small>
</span>
<span className="asset-browser-item-status">
{structural
? <><span className="tag">Contexto</span><small>{item.childrenCount} nivel{item.childrenCount === 1 ? '' : 'es'} inferior{item.childrenCount === 1 ? '' : 'es'}</small></>
: <><span className={`status-badge ${assetStatusClass(item.informationStatus)}`}>{assetStatusLabel(item.informationStatus)}</span><small>{assetOperationalStatusLabel(item.operationalStatus)}</small></>}
<span className={`status-badge ${assetStatusClass(item.informationStatus)}`}>{assetStatusLabel(item.informationStatus)}</span>
{item.inventoryFamily
? <small>{item.findingCount} Hallazgo{item.findingCount === 1 ? '' : 's'} asociado{item.findingCount === 1 ? '' : 's'}</small>
: <small>{item.childrenCount} registro{item.childrenCount === 1 ? '' : 's'} inferior{item.childrenCount === 1 ? '' : 'es'}</small>}
</span>
<Icon name="chevron" size={16} />
</Link>;
@@ -67,6 +72,7 @@ function InventoryCard({ item, href }: { item: InventoryBrowserItem; href: strin
function nextLevelLabel(typeCode: string | undefined) {
switch (typeCode?.toLowerCase()) {
case 'departamento': return 'Áreas';
case 'area': return 'Yacimientos';
case 'yacimiento': return 'Instalaciones';
case 'instalacion': return 'Subinstalaciones';
@@ -79,7 +85,8 @@ export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
const canCreate = hasPermission('assets.create');
const [searchParams] = useSearchParams();
const parentId = searchParams.get('parentId') ?? '';
const [areas, setAreas] = useState<InventoryBrowserArea[]>([]);
const [departments, setDepartments] = useState<InventoryBrowserDepartment[]>([]);
const [companies, setCompanies] = useState<InventoryBrowserCompany[]>([]);
const [children, setChildren] = useState<InventoryBrowserItem[]>([]);
const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
const [hasMore, setHasMore] = useState(false);
@@ -88,66 +95,53 @@ export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
useEffect(() => {
let active = true;
setLoading(true);
setError('');
setAreas([]);
setChildren([]);
setLineage([]);
setHasMore(false);
setLoading(true); setError(''); setDepartments([]); setCompanies([]); setChildren([]); setLineage([]); setHasMore(false);
const run = async () => {
if (!parentId) {
const response = await listInventoryAreas({
search: filters.search,
operationalAreaId: filters.operationalAreaId,
operatorCompanyId: filters.operatorCompanyId,
});
if (active) setAreas(response.data);
const [loadedDepartments, loadedCompanies] = await Promise.all([
listInventoryDepartments({ search: filters.search, operatorCompanyId: filters.operatorCompanyId }),
listInventoryCompanies({ search: filters.search }),
]);
if (active) { setDepartments(loadedDepartments.data); setCompanies(loadedCompanies.data); }
return;
}
const [loadedLineage, response] = await Promise.all([
getAssetLineage(parentId),
listInventoryChildren(parentId, { search: filters.search }),
]);
if (!active) return;
setLineage(loadedLineage.filter((item) => ['area','yacimiento','instalacion','subinstalacion'].includes(item.type.code.toLowerCase())));
setChildren(response.data);
setHasMore(response.meta.hasMore);
setLineage(loadedLineage.filter((item) => ['departamento','area','yacimiento','instalacion','subinstalacion'].includes(item.type.code.toLowerCase())));
setChildren(response.data); setHasMore(response.meta.hasMore);
};
run()
.catch((requestError) => active && setError(errorMessage(requestError)))
.finally(() => active && setLoading(false));
run().catch((requestError) => active && setError(errorMessage(requestError))).finally(() => active && setLoading(false));
return () => { active = false; };
}, [parentId, filters.search, filters.operationalAreaId, filters.operatorCompanyId]);
}, [parentId, filters.search, filters.operatorCompanyId]);
if (loading) return <LoadingBlock label="Cargando inventarios…" />;
if (loading) return <LoadingBlock label="Cargando Inventarios…" />;
if (!parentId) {
const realTotal = areas.reduce((sum, area) => sum + Number(area.inventoryCount ?? 0), 0);
return <div className="asset-browser-panel">
{error && <Alert>{error}</Alert>}
<div className="asset-browser-section-heading">
<div>
<span className="eyebrow">ESTRUCTURA TERRITORIAL</span>
<h2>Áreas</h2>
<p>Las Áreas y Yacimientos son contexto de navegación. El Inventario real comienza en las Instalaciones/Subinstalaciones efectivamente registradas.</p>
</div>
<div className="asset-browser-current-actions">
<span className="count-pill">{realTotal} Inventario real</span>
<span className="count-pill">{areas.length} Áreas</span>
</div>
<div><span className="eyebrow">INVENTARIO COMPLETO</span><h2>Departamentos</h2><p>Entrá por Departamento y navegá Área Yacimiento Instalación Subinstalación hasta llegar a su clasificación y Hallazgos asociados.</p></div>
<div className="asset-browser-current-actions"><span className="count-pill">{departments.length} Departamentos</span><span className="count-pill">{companies.length} Empresas</span></div>
</div>
{areas.length === 0
? <EmptyState title="No hay Áreas para mostrar" text="Probá con otra búsqueda o revisá el contexto seleccionado." />
: <div className="asset-browser-list">{areas.map((area) => <AreaCard key={area.id} area={area} href={navigationHref(searchParams, area.id)} />)}</div>}
{departments.length === 0
? <EmptyState title="Todavía no hay Departamentos" text="Creá el primer registro para comenzar a construir el Inventario manualmente." />
: <div className="asset-browser-list">{departments.map((department) => <DepartmentCard key={department.id} department={department} href={navigationHref(searchParams, department.id)} />)}</div>}
<div className="asset-browser-levels" aria-label="Estructura de Inventarios">
<div><span>1</span><strong>Área</strong><small>Ancla territorial</small></div><i></i>
<div><span>2</span><strong>Yacimiento</strong><small>Contexto dentro del Área</small></div><i></i>
<div><span>3</span><strong>Instalación</strong><small>Inventario real</small></div><i></i>
<div><span>4</span><strong>Subinstalación</strong><small>Inventario real</small></div>
<div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i></i>
<div><span>2</span><strong>Área</strong><small>dentro del Departamento</small></div><i></i>
<div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i>
<div><span>4</span><strong>Instalación</strong><small>clasificación técnica</small></div><i></i>
<div><span>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div>
</div>
<section className="asset-browser-group" style={{ marginTop: 20 }}>
<div className="asset-browser-group-heading"><div><h3>Empresas</h3><p>Maestro independiente. La Empresa se vincula al Área como operadora sin alterar la estructura física.</p></div><span>{companies.length}</span></div>
{companies.length === 0 ? <div className="inline-empty">Todavía no hay Empresas cargadas.</div> : <div className="asset-browser-list">{companies.map((company) => <CompanyCard key={company.id} company={company} />)}</div>}
</section>
</div>;
}
@@ -156,10 +150,7 @@ export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
<Link to="/inventarios">Inventarios</Link>
{lineage.map((item,index) => {
const isLast=index===lineage.length-1;
return <span className="asset-browser-crumb-part" key={item.id}>
<span></span>
{isLast ? <strong>{item.name}</strong> : <Link to={navigationHref(searchParams,item.id)}>{item.name}</Link>}
</span>;
return <span className="asset-browser-crumb-part" key={item.id}><span></span>{isLast ? <strong>{item.name}</strong> : <Link to={navigationHref(searchParams,item.id)}>{item.name}</Link>}</span>;
})}
</nav>;
@@ -168,31 +159,17 @@ export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
{error && <Alert>{error}</Alert>}
{hasMore && <Alert type="info">Este nivel tiene más de 200 registros. Usá la búsqueda para acotar el resultado.</Alert>}
<div className="asset-browser-current-heading">
<div>
<span className="eyebrow">{current?.type.name ?? 'INVENTARIO'}</span>
<h2>{current?.name ?? 'Nivel de Inventario'}</h2>
<p>{current?.code ?? ''}</p>
</div>
<div><span className="eyebrow">{current?.type.name ?? 'INVENTARIO'}</span><h2>{current?.name ?? 'Nivel de Inventario'}</h2><p>{current?.code ?? ''}</p></div>
<div className="asset-browser-current-actions">
{current && <Link className="button secondary" to={`/inventarios/${current.id}`}>Ver ficha</Link>}
{current && <Link className="button secondary" to={`/inventarios/${current.id}`}>Ver ficha{['instalacion','subinstalacion'].includes(current.type.code.toLowerCase()) ? ' y Hallazgos' : ''}</Link>}
{canCreate && current?.type.code.toLowerCase() !== 'subinstalacion' && <Link className="button primary" to={`/inventarios/nuevo?parentId=${parentId}`}><Icon name="plus" />Agregar aquí</Link>}
</div>
</div>
<section className="asset-browser-group">
<div className="asset-browser-group-heading">
<div><h3>{nextLevelLabel(current?.type.code)}</h3><p>La jerarquía permitida es Área Yacimiento Instalación Subinstalación.</p></div>
<span>{children.length}</span>
</div>
<div className="asset-browser-group-heading"><div><h3>{nextLevelLabel(current?.type.code)}</h3><p>Jerarquía: Departamento Área Yacimiento Instalación Subinstalación.</p></div><span>{children.length}</span></div>
{children.length === 0
? <EmptyState
title={current?.type.code.toLowerCase() === 'subinstalacion' ? 'Fin de la jerarquía' : 'No hay registros en este nivel'}
text={current?.type.code.toLowerCase() === 'yacimiento'
? 'Todavía no hay Instalaciones reales registradas en este Yacimiento.'
: current?.type.code.toLowerCase() === 'instalacion'
? 'Todavía no hay Subinstalaciones registradas en esta Instalación.'
: 'No hay registros que coincidan con la búsqueda actual.'}
/>
? <EmptyState title={current?.type.code.toLowerCase() === 'subinstalacion' ? 'Fin de la jerarquía' : 'No hay registros en este nivel'} text={current?.type.code.toLowerCase() === 'subinstalacion' ? 'Abrí la ficha para ver su clasificación y Hallazgos asociados.' : 'Agregá el primer registro de este nivel o revisá la búsqueda actual.'} />
: <div className="asset-browser-list">{children.map((item) => <InventoryCard key={item.id} item={item} href={navigationHref(searchParams,item.id)} />)}</div>}
</section>
</div>;
@@ -1,5 +1,6 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { getFindingCatalogAdmin } from '../../lib/api';
@@ -12,19 +13,25 @@ import type { InventoryFamily } from '../../lib/inventoryStructureApi';
const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
type ViewMode = 'ASSOCIATED' | 'ALL';
export function FindingCatalogTypeApplicabilityPanel() {
const [searchParams] = useSearchParams();
const requestedFamilyId = searchParams.get('familyId') ?? '';
const [families, setFamilies] = useState<InventoryFamily[]>([]);
const [catalog, setCatalog] = useState<FindingAdminCatalog>(EMPTY_CATALOG);
const [familyId, setFamilyId] = useState('');
const [enabled, setEnabled] = useState<Set<string>>(new Set());
const [reason, setReason] = useState('Actualización de aplicabilidad por clasificación de Inventario');
const [reason, setReason] = useState('Actualización de Hallazgos asociados a la clasificación de Inventario');
const [search, setSearch] = useState('');
const [viewMode, setViewMode] = useState<ViewMode>('ASSOCIATED');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const selectedFamily = families.find((family) => family.id === familyId) ?? null;
const savedIds = useMemo(() => new Set(selectedFamily?.findingItemIds ?? []), [selectedFamily]);
useEffect(() => {
Promise.all([listInventoryFamiliesAdmin(), getFindingCatalogAdmin()])
@@ -32,15 +39,19 @@ export function FindingCatalogTypeApplicabilityPanel() {
const activeFamilies = loadedFamilies.filter((family) => family.isActive !== false);
setFamilies(activeFamilies);
setCatalog(loadedCatalog);
setFamilyId(activeFamilies[0]?.id ?? '');
setFamilyId(activeFamilies.some((family) => family.id === requestedFamilyId)
? requestedFamilyId
: activeFamilies[0]?.id ?? '');
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, []);
}, [requestedFamilyId]);
useEffect(() => {
const selected = families.find((family) => family.id === familyId);
setEnabled(new Set(selected?.findingItemIds ?? []));
setViewMode('ASSOCIATED');
setSearch('');
setSuccess('');
setError('');
}, [familyId, families]);
@@ -53,7 +64,7 @@ export function FindingCatalogTypeApplicabilityPanel() {
catalog.categories.map((category) => [category.id, category.name]),
), [catalog.categories]);
const visible = useMemo(() => {
const available = useMemo(() => {
const needle = search.trim().toLocaleLowerCase('es-AR');
return catalog.items.filter((item) =>
item.isActive
@@ -63,12 +74,23 @@ export function FindingCatalogTypeApplicabilityPanel() {
);
}, [catalog.items, search, activeCategoryIds, categoryName]);
const visible = useMemo(() => viewMode === 'ASSOCIATED'
? available.filter((item) => savedIds.has(item.id))
: available,
[available, savedIds, viewMode]);
const toggle = (id: string) => setEnabled((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const selectVisible = () => setEnabled((current) => {
const next = new Set(current);
visible.forEach((item) => next.add(item.id));
return next;
});
const save = async () => {
if (!selectedFamily) return;
setSaving(true); setError(''); setSuccess('');
@@ -82,6 +104,7 @@ export function FindingCatalogTypeApplicabilityPanel() {
setFamilies((current) => current.map((family) => family.id === selectedFamily.id
? { ...family, findingItemIds: itemIds, findingCount: itemIds.length }
: family));
setViewMode('ASSOCIATED');
setSuccess(`Hallazgos guardados para ${selectedFamily.name}.`);
} catch (requestError) {
setError(errorMessage(requestError));
@@ -98,9 +121,9 @@ export function FindingCatalogTypeApplicabilityPanel() {
<div>
<span className="eyebrow">APLICABILIDAD POR INSTALACIÓN / SUBINSTALACIÓN</span>
<h2>Qué Hallazgos verá el inspector</h2>
<p className="section-copy">Los Hallazgos se vinculan a la clasificación concreta del elemento, no a una función. La opción OTROS permanece siempre disponible en la APK.</p>
<p className="section-copy">Al elegir una clasificación se filtra inmediatamente a sus Hallazgos asociados. Para modificar la relación, abrí Todos para vincular.</p>
</div>
<span className="count-pill">{enabled.size} vinculados</span>
<span className="count-pill">{savedIds.size} vinculados</span>
</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="form-grid finding-applicability-toolbar">
@@ -112,21 +135,39 @@ export function FindingCatalogTypeApplicabilityPanel() {
</option>)}
</SearchableSelect>
</label>
<label className="field"><span>Buscar Hallazgo</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>
<label className="field"><span>Buscar dentro de {viewMode === 'ASSOCIATED' ? 'los asociados' : 'todo el Catálogo'}</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>
</div>
{selectedFamily && <div className="temporal-notice">
<Icon name="layers" />
<p><strong>{selectedFamily.level === 'INSTALLATION' ? 'Instalación' : 'Subinstalación'}:</strong> {selectedFamily.parentFamilyName ? `${selectedFamily.parentFamilyName}` : ''}{selectedFamily.name}. Actualmente tiene {selectedFamily.findingCount ?? enabled.size} Hallazgo{(selectedFamily.findingCount ?? enabled.size) === 1 ? '' : 's'} asociado{(selectedFamily.findingCount ?? enabled.size) === 1 ? '' : 's'}.</p>
<p><strong>{selectedFamily.level === 'INSTALLATION' ? 'Instalación' : 'Subinstalación'}:</strong> {selectedFamily.parentFamilyName ? `${selectedFamily.parentFamilyName}` : ''}{selectedFamily.name}. Tiene {savedIds.size} Hallazgo{savedIds.size === 1 ? '' : 's'} asociado{savedIds.size === 1 ? '' : 's'}.</p>
</div>}
<div className="catalog-selection-actions">
<button type="button" className="button secondary" onClick={() => setEnabled(new Set(visible.map((item) => item.id)))}>Seleccionar visibles</button>
<button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button>
<div className="quick-view-row" style={{ marginBottom: 12 }}>
<button type="button" className={viewMode === 'ASSOCIATED' ? 'active' : ''} onClick={() => { setViewMode('ASSOCIATED'); setSearch(''); }}>Asociados ({savedIds.size})</button>
<button type="button" className={viewMode === 'ALL' ? 'active' : ''} onClick={() => { setViewMode('ALL'); setSearch(''); }}>Todos para vincular ({catalog.items.filter((item) => item.isActive && activeCategoryIds.has(item.categoryId)).length})</button>
</div>
<div className="finding-selection-list">{visible.map((item) => <label className="finding-selection-row" key={item.id}>
<input type="checkbox" checked={enabled.has(item.id)} onChange={() => toggle(item.id)} />
<span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span>
</label>)}</div>
<label className="field"><span>Motivo del cambio</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} placeholder="Ej.: Hallazgos aplicables a esta Subinstalación según criterio técnico…" /></label>
<div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5 || !selectedFamily} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar Hallazgos vinculados'}</button></div>
{viewMode === 'ASSOCIATED' && visible.length === 0
? <div className="inline-empty"><strong>{search.trim() ? 'Ningún Hallazgo asociado coincide con la búsqueda.' : 'Esta clasificación todavía no tiene Hallazgos asociados.'}</strong><br />{!search.trim() && 'Abrí “Todos para vincular” para elegirlos.'}</div>
: <>
{viewMode === 'ALL' && <div className="catalog-selection-actions">
<button type="button" className="button secondary" onClick={selectVisible}>Seleccionar visibles</button>
<button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button>
</div>}
<div className="finding-selection-list">{visible.map((item) => viewMode === 'ALL'
? <label className="finding-selection-row" key={item.id}>
<input type="checkbox" checked={enabled.has(item.id)} onChange={() => toggle(item.id)} />
<span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span>
</label>
: <div className="finding-selection-row" key={item.id}>
<span className="asset-symbol"><Icon name="check" size={14} /></span>
<span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span>
</div>)}</div>
</>}
{viewMode === 'ALL' && <>
<label className="field"><span>Motivo del cambio</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} /></label>
<div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5 || !selectedFamily} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : `Guardar ${enabled.size} asociados`}</button></div>
</>}
</section>;
}