F6 · Modelo definitivo de Inventarios + APK Android (#29)
Jerarquía física definitiva, compatibilidades N↔N de clasificaciones, Hallazgos y campos técnicos por familia, navegación WEB/Android y APK F6 0.14.0-debug.
This commit is contained in:
@@ -1,59 +1,98 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useAuth } from '../../auth/AuthContext';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
|
||||
import { Icon } from '../../components/Icon';
|
||||
import { getFindingCatalogAssetSelection, replaceFindingCatalogAssetSelection } from '../../lib/api';
|
||||
import type { FindingCatalogAssetSelection } from '../../lib/api';
|
||||
import {
|
||||
getInventoryFamilyFindings,
|
||||
getInventoryTechnicalValues,
|
||||
} from '../../lib/inventoryStructureApi';
|
||||
import type {
|
||||
InventoryFamilyFinding,
|
||||
InventoryTechnicalValues,
|
||||
} from '../../lib/inventoryStructureApi';
|
||||
import { AssetTechnicalDataPanel } from './AssetTechnicalDataPanel';
|
||||
|
||||
export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: string; canManage: boolean }) {
|
||||
const [selection, setSelection] = useState<FindingCatalogAssetSelection | null>(null);
|
||||
const [enabled, setEnabled] = useState<Set<string>>(new Set());
|
||||
const [reason, setReason] = useState('');
|
||||
const { hasPermission } = useAuth();
|
||||
const canEditTechnical = hasPermission('assets.update');
|
||||
const [technical, setTechnical] = useState<InventoryTechnicalValues | null>(null);
|
||||
const [items, setItems] = useState<InventoryFamilyFinding[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [unsupported, setUnsupported] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const load = () => getFindingCatalogAssetSelection(assetId).then((loaded) => {
|
||||
setSelection(loaded);
|
||||
setEnabled(new Set(loaded.items.filter((item) => item.enabled).map((item) => item.id)));
|
||||
});
|
||||
|
||||
useEffect(() => { load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, [assetId]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setUnsupported(false);
|
||||
setError('');
|
||||
getInventoryTechnicalValues(assetId)
|
||||
.then(async (loaded) => {
|
||||
const findings = await getInventoryFamilyFindings(loaded.family.id);
|
||||
if (!active) return;
|
||||
setTechnical(loaded);
|
||||
setItems(findings.items);
|
||||
})
|
||||
.catch((requestError) => {
|
||||
if (!active) return;
|
||||
const message = errorMessage(requestError);
|
||||
if (message.includes('no tiene clasificación técnica')) setUnsupported(true);
|
||||
else setError(message);
|
||||
})
|
||||
.finally(() => active && setLoading(false));
|
||||
return () => { active = false; };
|
||||
}, [assetId]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const needle = search.trim().toLocaleLowerCase();
|
||||
return selection?.items.filter((item) => !needle || [item.title, item.code, item.categoryName]
|
||||
.some((value) => value.toLocaleLowerCase().includes(needle))) ?? [];
|
||||
}, [selection, search]);
|
||||
if (!needle) return items;
|
||||
return items.filter((item) => [item.title, item.code, item.categoryName, item.legalBasis ?? '']
|
||||
.some((value) => value.toLocaleLowerCase().includes(needle)));
|
||||
}, [items, search]);
|
||||
|
||||
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 save = async () => {
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const saved = await replaceFindingCatalogAssetSelection(assetId, { enabledItemIds: [...enabled], reason });
|
||||
setSelection(saved);
|
||||
setEnabled(new Set(saved.items.filter((item) => item.enabled).map((item) => item.id)));
|
||||
setReason('');
|
||||
setSuccess('Subconjunto de hallazgos actualizado para este objeto.');
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (loading) return <div className="panel"><LoadingBlock label="Cargando hallazgos aplicables…" /></div>;
|
||||
if (!selection) return <Alert>{error || 'No se pudo cargar la configuración.'}</Alert>;
|
||||
|
||||
return <section className="panel asset-finding-catalog-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS APLICABLES</span><h2>{selection.asset.name}</h2><p className="section-copy">Base: {selection.asset.assetTypeName}. Las excepciones de esta pantalla afectan sólo a este objeto del Inventario.</p></div><span className="count-pill">{enabled.size} habilitados</span></div>
|
||||
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
||||
{!selection.typeConfigured && <div className="temporal-notice"><Icon name="alert" /><p><strong>El tipo técnico todavía no tiene un catálogo restringido.</strong> Por compatibilidad, su base actual incluye todo el catálogo activo. Podés configurar primero el tipo general en “Catálogo de hallazgos”.</p></div>}
|
||||
<label className="search-field"><Icon name="search" /><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Buscar hallazgo aplicable…" /></label>
|
||||
<div className="finding-selection-list">{visible.map((item) => <label className={`finding-selection-row ${item.assetOverride !== null ? 'override' : ''}`} key={item.id}><input type="checkbox" disabled={!canManage} checked={enabled.has(item.id)} onChange={() => toggle(item.id)} /><span><strong>{item.title}</strong><small>{item.categoryName} · {item.code} · base del tipo: {item.typeDefaultEnabled ? 'sí' : 'no'}{item.assetOverride !== null ? ' · excepción de este objeto' : ''}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label>)}</div>
|
||||
{canManage && <><label className="field"><span>Motivo de la excepción</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} placeholder="Explicá por qué este objeto usa un subconjunto distinto…" /></label><div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar excepciones'}</button></div></>}
|
||||
if (loading) return <div className="panel"><LoadingBlock label="Cargando clasificación y Hallazgos aplicables…" /></div>;
|
||||
if (error) return <Alert>{error}</Alert>;
|
||||
if (unsupported || !technical) return <section className="panel asset-finding-catalog-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS APLICABLES</span><h2>Sin clasificación técnica</h2><p className="section-copy">Departamento, Área y Yacimiento son niveles estructurales. Los Hallazgos se registran sobre Instalaciones y Subinstalaciones clasificadas.</p></div></div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
return <div className="asset-tab-stack">
|
||||
<AssetTechnicalDataPanel assetId={assetId} canEdit={canEditTechnical} />
|
||||
<section className="panel asset-finding-catalog-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="eyebrow">HALLAZGOS APLICABLES</span>
|
||||
<h2>{technical.family.name}</h2>
|
||||
<p className="section-copy">Se muestran únicamente los Hallazgos asociados a esta clasificación técnica. Éste es el mismo catálogo que consume la APK para este objeto.</p>
|
||||
</div>
|
||||
<span className="count-pill">{items.length} asociados</span>
|
||||
</div>
|
||||
|
||||
{canManage && <div className="temporal-notice">
|
||||
<Icon name="layers" />
|
||||
<p><strong>¿Falta un Hallazgo?</strong> Administrá la clasificación en Configuración de Inventarios y el cambio se aplicará a todos los objetos de este mismo tipo técnico.</p>
|
||||
<Link className="button secondary" to="/admin/asset-types">Configurar Inventarios</Link>
|
||||
</div>}
|
||||
|
||||
{items.length > 0 && <label className="search-field">
|
||||
<Icon name="search" />
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={`Buscar entre los ${items.length} Hallazgos asociados…`} />
|
||||
</label>}
|
||||
|
||||
{items.length === 0
|
||||
? <div className="inline-empty">Esta clasificación todavía no tiene Hallazgos asociados. Desde Configuración de Inventarios podés vincular los que correspondan.</div>
|
||||
: visible.length === 0
|
||||
? <div className="inline-empty">No hay Hallazgos asociados que coincidan con la búsqueda.</div>
|
||||
: <div className="finding-selection-list">{visible.map((item) => <div className="finding-selection-row" key={item.id}>
|
||||
<span className="asset-symbol"><Icon name="alert" size={15} /></span>
|
||||
<span>
|
||||
<strong>{item.title}</strong>
|
||||
<small>{item.categoryName} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small>
|
||||
{item.legalBasis && <small>{item.legalBasis}</small>}
|
||||
</span>
|
||||
</div>)}</div>}
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SearchableSelect } from '../../components/SearchableSelect';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
|
||||
import { Icon } from '../../components/Icon';
|
||||
import { getInventoryTechnicalValues, replaceInventoryTechnicalValues } from '../../lib/inventoryStructureApi';
|
||||
import type { InventoryFamilyAttribute, InventoryTechnicalValues } from '../../lib/inventoryStructureApi';
|
||||
|
||||
function localDateTime(value:unknown){
|
||||
if(typeof value!=='string'||!value)return '';
|
||||
const date=new Date(value); if(Number.isNaN(date.getTime()))return value.slice(0,16);
|
||||
const local=new Date(date.getTime()-date.getTimezoneOffset()*60000); return local.toISOString().slice(0,16);
|
||||
}
|
||||
function displayValue(definition:InventoryFamilyAttribute,value:unknown){
|
||||
if(value===undefined||value===null||value==='')return 'Sin completar';
|
||||
if(definition.dataType==='BOOLEAN')return value?'Sí':'No';
|
||||
if(definition.dataType==='DATETIME'&&typeof value==='string')return new Date(value).toLocaleString('es-AR');
|
||||
return `${String(value)}${definition.unit?` ${definition.unit}`:''}`;
|
||||
}
|
||||
|
||||
export function AssetTechnicalDataPanel({assetId,canEdit}:{assetId:string;canEdit:boolean}){
|
||||
const [data,setData]=useState<InventoryTechnicalValues|null>(null);
|
||||
const [values,setValues]=useState<Record<string,unknown>>({});
|
||||
const [loading,setLoading]=useState(true); const [saving,setSaving]=useState(false);
|
||||
const [unsupported,setUnsupported]=useState(false); const [error,setError]=useState(''); const [success,setSuccess]=useState('');
|
||||
useEffect(()=>{
|
||||
let active=true; setLoading(true); setUnsupported(false); setError('');
|
||||
getInventoryTechnicalValues(assetId).then((loaded)=>{if(!active)return;setData(loaded);setValues(Object.fromEntries(loaded.definitions.map((definition)=>{
|
||||
const raw=loaded.values[definition.id]; return [definition.id,definition.dataType==='DATETIME'?localDateTime(raw):raw??''];
|
||||
})))}).catch((requestError)=>{if(!active)return;const message=errorMessage(requestError);if(message.includes('no tiene clasificación técnica'))setUnsupported(true);else setError(message);}).finally(()=>active&&setLoading(false));
|
||||
return()=>{active=false};
|
||||
},[assetId]);
|
||||
if(loading)return <div className="form-section"><LoadingBlock label="Cargando datos técnicos…" /></div>;
|
||||
if(unsupported)return null;
|
||||
if(error&&!data)return <div className="form-section"><Alert>{error}</Alert></div>;
|
||||
if(!data)return null;
|
||||
const activeDefinitions=data.definitions.filter((item)=>item.isActive);
|
||||
const save=async()=>{
|
||||
setSaving(true);setError('');setSuccess('');
|
||||
try{
|
||||
const normalized:Record<string,unknown>={};
|
||||
for(const definition of activeDefinitions){const raw=values[definition.id];if(raw===''||raw===null||raw===undefined)continue;
|
||||
if(definition.dataType==='NUMBER')normalized[definition.id]=Number(raw);
|
||||
else if(definition.dataType==='BOOLEAN')normalized[definition.id]=Boolean(raw);
|
||||
else if(definition.dataType==='DATETIME')normalized[definition.id]=new Date(String(raw)).toISOString();
|
||||
else normalized[definition.id]=raw;
|
||||
}
|
||||
const saved=await replaceInventoryTechnicalValues(assetId,normalized);setData(saved);setSuccess('Datos técnicos guardados.');
|
||||
}catch(requestError){setError(errorMessage(requestError));}finally{setSaving(false)}
|
||||
};
|
||||
return <div className="form-section">
|
||||
<div><h2>Información técnica · {data.family.name}</h2><p className="section-copy">Estos campos pertenecen a la clasificación concreta del elemento, no al nivel genérico.</p></div>
|
||||
{error&&<Alert>{error}</Alert>}{success&&<Alert type="success">{success}</Alert>}
|
||||
{activeDefinitions.length===0?<div className="inline-empty">Esta clasificación todavía no tiene campos técnicos configurados.</div>:canEdit?<div className="dynamic-attributes">{activeDefinitions.map((definition)=>{
|
||||
const value=values[definition.id];const label=<span>{definition.name}{definition.unit?` (${definition.unit})`:''}{definition.isRequired?<em>obligatorio</em>:<em>opcional</em>}</span>;
|
||||
if(definition.dataType==='BOOLEAN')return <label className="check-row attribute-check" key={definition.id}><input type="checkbox" checked={Boolean(value)} onChange={(event)=>setValues((current)=>({...current,[definition.id]:event.target.checked}))}/><span><strong>{definition.name}</strong><small>{definition.code}</small></span></label>;
|
||||
if(definition.dataType==='SELECT')return <label className="field" key={definition.id}>{label}<SearchableSelect value={String(value??'')} onChange={(event)=>setValues((current)=>({...current,[definition.id]:event.target.value}))} required={definition.isRequired}><option value="">Seleccionar…</option>{definition.options?.map((option)=><option key={option} value={option}>{option}</option>)}</SearchableSelect></label>;
|
||||
const inputType=definition.dataType==='NUMBER'?'number':definition.dataType==='DATE'?'date':definition.dataType==='DATETIME'?'datetime-local':'text';
|
||||
return <label className="field" key={definition.id}>{label}<input type={inputType} value={String(value??'')} step={definition.dataType==='NUMBER'?'any':undefined} required={definition.isRequired} onChange={(event)=>setValues((current)=>({...current,[definition.id]:event.target.value}))}/></label>;
|
||||
})}</div>:<div className="attribute-list">{activeDefinitions.map((definition)=><div className="attribute-card" key={definition.id}><span className="asset-symbol"><Icon name="layers" size={15}/></span><span><strong>{definition.name}</strong><small>{definition.code}</small></span><span>{displayValue(definition,data.values[definition.id])}</span></div>)}</div>}
|
||||
{canEdit&&activeDefinitions.length>0&&<div className="form-actions"><button type="button" className="button primary" disabled={saving} onClick={()=>void save()}><Icon name="check" />{saving?'Guardando…':'Guardar datos técnicos'}</button></div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -12,9 +12,14 @@ import {
|
||||
import type { InventoryFamily } from '../../lib/inventoryStructureApi';
|
||||
|
||||
const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
|
||||
|
||||
type ViewMode = 'ASSOCIATED' | 'ALL';
|
||||
|
||||
function familyContext(family: InventoryFamily) {
|
||||
if (family.level === 'INSTALLATION') return `Instalación · ${family.name}`;
|
||||
const parents = family.parentFamilies.map((parent) => parent.name).join(', ');
|
||||
return `Subinstalación · ${family.name}${parents ? ` · compatible con ${parents}` : ''}`;
|
||||
}
|
||||
|
||||
export function FindingCatalogTypeApplicabilityPanel() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const requestedFamilyId = searchParams.get('familyId') ?? '';
|
||||
@@ -37,137 +42,50 @@ export function FindingCatalogTypeApplicabilityPanel() {
|
||||
Promise.all([listInventoryFamiliesAdmin(), getFindingCatalogAdmin()])
|
||||
.then(([loadedFamilies, loadedCatalog]) => {
|
||||
const activeFamilies = loadedFamilies.filter((family) => family.isActive !== false);
|
||||
setFamilies(activeFamilies);
|
||||
setCatalog(loadedCatalog);
|
||||
setFamilyId(activeFamilies.some((family) => family.id === requestedFamilyId)
|
||||
? requestedFamilyId
|
||||
: activeFamilies[0]?.id ?? '');
|
||||
setFamilies(activeFamilies); setCatalog(loadedCatalog);
|
||||
setFamilyId(activeFamilies.some((family) => family.id === requestedFamilyId) ? requestedFamilyId : activeFamilies[0]?.id ?? '');
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
.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('');
|
||||
setEnabled(new Set(selected?.findingItemIds ?? [])); setViewMode('ASSOCIATED'); setSearch(''); setSuccess(''); setError('');
|
||||
}, [familyId, families]);
|
||||
|
||||
const activeCategoryIds = useMemo(() => new Set(
|
||||
catalog.categories.filter((category) => category.isActive).map((category) => category.id),
|
||||
), [catalog.categories]);
|
||||
|
||||
const categoryName = useMemo(() => new Map(
|
||||
catalog.categories.map((category) => [category.id, category.name]),
|
||||
), [catalog.categories]);
|
||||
|
||||
const activeCategoryIds = useMemo(() => new Set(catalog.categories.filter((category) => category.isActive).map((category) => category.id)), [catalog.categories]);
|
||||
const categoryName = useMemo(() => new Map(catalog.categories.map((category) => [category.id, category.name])), [catalog.categories]);
|
||||
const available = useMemo(() => {
|
||||
const needle = search.trim().toLocaleLowerCase('es-AR');
|
||||
return catalog.items.filter((item) =>
|
||||
item.isActive
|
||||
&& activeCategoryIds.has(item.categoryId)
|
||||
&& (!needle || [item.title, item.code, categoryName.get(item.categoryId) ?? '']
|
||||
.some((value) => value.toLocaleLowerCase('es-AR').includes(needle))),
|
||||
);
|
||||
return catalog.items.filter((item) => item.isActive && 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 visible = useMemo(() => viewMode === 'ASSOCIATED' ? available.filter((item) => savedIds.has(item.id)) : available, [available, savedIds, viewMode]);
|
||||
|
||||
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 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('');
|
||||
try {
|
||||
const saved = await replaceInventoryFamilyFindings(selectedFamily.id, {
|
||||
itemIds: [...enabled],
|
||||
reason,
|
||||
});
|
||||
const itemIds = saved.items.map((item) => item.id);
|
||||
setEnabled(new Set(itemIds));
|
||||
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));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
const saved = await replaceInventoryFamilyFindings(selectedFamily.id, { itemIds: [...enabled], reason });
|
||||
const itemIds = saved.items.map((item) => item.id); setEnabled(new Set(itemIds));
|
||||
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)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (loading) return <div className="panel"><LoadingBlock label="Cargando aplicabilidad…" /></div>;
|
||||
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">
|
||||
<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">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">{savedIds.size} vinculados</span>
|
||||
</div>
|
||||
<div className="panel-heading"><div><span className="eyebrow">APLICABILIDAD POR CLASIFICACIÓN</span><h2>Qué Hallazgos verá el inspector</h2><p className="section-copy">Al elegir una clasificación se muestran sólo sus Hallazgos asociados. “Todos para vincular” se usa exclusivamente para modificar la relación.</p></div><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">
|
||||
<label className="field">
|
||||
<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 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}. Tiene {savedIds.size} Hallazgo{savedIds.size === 1 ? '' : 's'} asociado{savedIds.size === 1 ? '' : 's'}.</p>
|
||||
</div>}
|
||||
|
||||
<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>
|
||||
|
||||
{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>
|
||||
</>}
|
||||
<div className="form-grid finding-applicability-toolbar"><label className="field"><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}>{familyContext(family)}</option>)}</SearchableSelect></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.name}:</strong> {selectedFamily.level === 'SUBINSTALLATION' && selectedFamily.parentFamilies.length ? `compatible con ${selectedFamily.parentFamilies.map((parent) => parent.name).join(', ')}. ` : ''}Tiene {savedIds.size} Hallazgo{savedIds.size === 1 ? '' : 's'} asociado{savedIds.size === 1 ? '' : 's'}.</p></div>}
|
||||
<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>
|
||||
{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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user