fix(web): configure finding applicability by inventory classification

This commit is contained in:
2026-09-08 21:54:29 -03:00
parent afa11918a6
commit 741b2f2d8e
@@ -2,54 +2,66 @@ import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback'; import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon'; import { Icon } from '../../components/Icon';
import { getFindingCatalogAdmin } from '../../lib/api';
import type { FindingAdminCatalog } from '../../lib/api';
import { import {
getFindingCatalogAssetTypeSelection, listInventoryFamiliesAdmin,
listAssetTypes, replaceInventoryFamilyFindings,
replaceFindingCatalogAssetTypeSelection, } from '../../lib/inventoryStructureApi';
} from '../../lib/api'; import type { InventoryFamily } from '../../lib/inventoryStructureApi';
import type { AssetType, FindingCatalogAssetTypeSelection } from '../../lib/api';
const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
export function FindingCatalogTypeApplicabilityPanel() { export function FindingCatalogTypeApplicabilityPanel() {
const [types, setTypes] = useState<AssetType[]>([]); const [families, setFamilies] = useState<InventoryFamily[]>([]);
const [typeId, setTypeId] = useState(''); const [catalog, setCatalog] = useState<FindingAdminCatalog>(EMPTY_CATALOG);
const [selection, setSelection] = useState<FindingCatalogAssetTypeSelection | null>(null); const [familyId, setFamilyId] = useState('');
const [enabled, setEnabled] = useState<Set<string>>(new Set()); const [enabled, setEnabled] = useState<Set<string>>(new Set());
const [reason, setReason] = useState(''); const [reason, setReason] = useState('Actualización de aplicabilidad por clasificación de Inventario');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
const selectedFamily = families.find((family) => family.id === familyId) ?? null;
useEffect(() => { useEffect(() => {
listAssetTypes() Promise.all([listInventoryFamiliesAdmin(), getFindingCatalogAdmin()])
.then((loaded) => { .then(([loadedFamilies, loadedCatalog]) => {
const technical = loaded.filter((type) => type.isActive && type.operationalRole === 'GENERIC'); const activeFamilies = loadedFamilies.filter((family) => family.isActive !== false);
setTypes(technical); setFamilies(activeFamilies);
setTypeId(technical[0]?.id ?? ''); setCatalog(loadedCatalog);
setFamilyId(activeFamilies[0]?.id ?? '');
}) })
.catch((requestError) => setError(errorMessage(requestError))) .catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!typeId) { setSelection(null); return; } const selected = families.find((family) => family.id === familyId);
setLoading(true); setError(''); setSuccess(''); setEnabled(new Set(selected?.findingItemIds ?? []));
getFindingCatalogAssetTypeSelection(typeId) setSuccess('');
.then((loaded) => { setError('');
setSelection(loaded); }, [familyId, families]);
setEnabled(new Set(loaded.items.filter((item) => item.enabled).map((item) => item.id)));
setReason(loaded.reason ?? ''); const activeCategoryIds = useMemo(() => new Set(
}) catalog.categories.filter((category) => category.isActive).map((category) => category.id),
.catch((requestError) => setError(errorMessage(requestError))) ), [catalog.categories]);
.finally(() => setLoading(false));
}, [typeId]); const categoryName = useMemo(() => new Map(
catalog.categories.map((category) => [category.id, category.name]),
), [catalog.categories]);
const visible = useMemo(() => { const visible = useMemo(() => {
const needle = search.trim().toLocaleLowerCase(); const needle = search.trim().toLocaleLowerCase('es-AR');
return selection?.items.filter((item) => !needle || [item.title, item.code, item.categoryName] return catalog.items.filter((item) =>
.some((value) => value.toLocaleLowerCase().includes(needle))) ?? []; item.isActive
}, [selection, search]); && activeCategoryIds.has(item.categoryId)
&& (!needle || [item.title, item.code, categoryName.get(item.categoryId) ?? '']
.some((value) => value.toLocaleLowerCase('es-AR').includes(needle))),
);
}, [catalog.items, search, activeCategoryIds, categoryName]);
const toggle = (id: string) => setEnabled((current) => { const toggle = (id: string) => setEnabled((current) => {
const next = new Set(current); const next = new Set(current);
@@ -58,17 +70,19 @@ export function FindingCatalogTypeApplicabilityPanel() {
}); });
const save = async () => { const save = async () => {
if (!selection) return; if (!selectedFamily) return;
setSaving(true); setError(''); setSuccess(''); setSaving(true); setError(''); setSuccess('');
try { try {
const saved = await replaceFindingCatalogAssetTypeSelection(selection.assetType.id, { const saved = await replaceInventoryFamilyFindings(selectedFamily.id, {
enabledItemIds: [...enabled], itemIds: [...enabled],
reason, reason,
}); });
setSelection(saved); const itemIds = saved.items.map((item) => item.id);
setEnabled(new Set(saved.items.filter((item) => item.enabled).map((item) => item.id))); setEnabled(new Set(itemIds));
setReason(saved.reason ?? reason); setFamilies((current) => current.map((family) => family.id === selectedFamily.id
setSuccess(`Aplicabilidad guardada para ${saved.assetType.name}.`); ? { ...family, findingItemIds: itemIds, findingCount: itemIds.length }
: family));
setSuccess(`Hallazgos guardados para ${selectedFamily.name}.`);
} catch (requestError) { } catch (requestError) {
setError(errorMessage(requestError)); setError(errorMessage(requestError));
} finally { } finally {
@@ -76,20 +90,43 @@ export function FindingCatalogTypeApplicabilityPanel() {
} }
}; };
if (loading && types.length === 0) return <div className="panel"><LoadingBlock label="Cargando aplicabilidad…" /></div>; if (loading) return <div className="panel"><LoadingBlock label="Cargando aplicabilidad…" /></div>;
if (types.length === 0) return null; if (families.length === 0) return <Alert>No hay clasificaciones de Instalación/Subinstalación disponibles. Crealas primero en Configuración de Inventarios.</Alert>;
return <section className="panel finding-applicability-panel"> return <section className="panel finding-applicability-panel">
<div className="panel-heading"><div><span className="eyebrow">APLICABILIDAD POR TIPO TÉCNICO</span><h2>Qué hallazgos verá el inspector</h2><p className="section-copy">Configurá el catálogo base para cada tipo de elemento del Inventario. Después se pueden hacer excepciones por objeto concreto.</p></div><span className="count-pill">{enabled.size} habilitados</span></div> <div className="panel-heading">
<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>
</div>
<span className="count-pill">{enabled.size} vinculados</span>
</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>} {error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="form-grid finding-applicability-toolbar"> <div className="form-grid finding-applicability-toolbar">
<label className="field"><span>Tipo técnico</span><SearchableSelect value={typeId} onChange={(event) => setTypeId(event.target.value)}>{types.map((type) => <option value={type.id} key={type.id}>{type.name}</option>)}</SearchableSelect></label> <label className="field">
<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> <span>Clasificación de Inventario</span>
<SearchableSelect value={familyId} onChange={(event) => setFamilyId(event.target.value)}>
{families.map((family) => <option value={family.id} key={family.id}>
{family.level === 'INSTALLATION' ? 'Instalación' : 'Subinstalación'} · {family.parentFamilyName ? `${family.parentFamilyName}` : ''}{family.name}
</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>
</div> </div>
{selection && !selection.configured && <div className="temporal-notice"><Icon name="alert" /><p><strong>Este tipo todavía no fue configurado.</strong> Para no romper el funcionamiento actual, hoy recibe todo el catálogo activo. Al guardar esta pantalla, sólo quedarán habilitados los seleccionados.</p></div>} {selectedFamily && <div className="temporal-notice">
<div className="catalog-selection-actions"><button type="button" className="button secondary" onClick={() => setEnabled(new Set(selection?.items.map((item) => item.id) ?? []))}>Seleccionar todos</button><button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button></div> <Icon name="layers" />
<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>{item.categoryName} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label>)}</div> <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>
<label className="field"><span>Motivo de configuración</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} placeholder="Ej.: catálogo aplicable a tanques según criterio técnico de Hidrocarburos…" /></label> </div>}
<div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar aplicabilidad'}</button></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>
<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>
</section>; </section>;
} }