feat(f6): mostrar compatibilidades múltiples en catálogo
This commit is contained in:
@@ -12,9 +12,14 @@ import {
|
|||||||
import type { InventoryFamily } from '../../lib/inventoryStructureApi';
|
import type { InventoryFamily } from '../../lib/inventoryStructureApi';
|
||||||
|
|
||||||
const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
|
const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
|
||||||
|
|
||||||
type ViewMode = 'ASSOCIATED' | 'ALL';
|
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() {
|
export function FindingCatalogTypeApplicabilityPanel() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const requestedFamilyId = searchParams.get('familyId') ?? '';
|
const requestedFamilyId = searchParams.get('familyId') ?? '';
|
||||||
@@ -37,137 +42,50 @@ export function FindingCatalogTypeApplicabilityPanel() {
|
|||||||
Promise.all([listInventoryFamiliesAdmin(), getFindingCatalogAdmin()])
|
Promise.all([listInventoryFamiliesAdmin(), getFindingCatalogAdmin()])
|
||||||
.then(([loadedFamilies, loadedCatalog]) => {
|
.then(([loadedFamilies, loadedCatalog]) => {
|
||||||
const activeFamilies = loadedFamilies.filter((family) => family.isActive !== false);
|
const activeFamilies = loadedFamilies.filter((family) => family.isActive !== false);
|
||||||
setFamilies(activeFamilies);
|
setFamilies(activeFamilies); setCatalog(loadedCatalog);
|
||||||
setCatalog(loadedCatalog);
|
setFamilyId(activeFamilies.some((family) => family.id === requestedFamilyId) ? requestedFamilyId : activeFamilies[0]?.id ?? '');
|
||||||
setFamilyId(activeFamilies.some((family) => family.id === requestedFamilyId)
|
|
||||||
? requestedFamilyId
|
|
||||||
: activeFamilies[0]?.id ?? '');
|
|
||||||
})
|
})
|
||||||
.catch((requestError) => setError(errorMessage(requestError)))
|
.catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [requestedFamilyId]);
|
}, [requestedFamilyId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selected = families.find((family) => family.id === familyId);
|
const selected = families.find((family) => family.id === familyId);
|
||||||
setEnabled(new Set(selected?.findingItemIds ?? []));
|
setEnabled(new Set(selected?.findingItemIds ?? [])); setViewMode('ASSOCIATED'); setSearch(''); setSuccess(''); setError('');
|
||||||
setViewMode('ASSOCIATED');
|
|
||||||
setSearch('');
|
|
||||||
setSuccess('');
|
|
||||||
setError('');
|
|
||||||
}, [familyId, families]);
|
}, [familyId, families]);
|
||||||
|
|
||||||
const activeCategoryIds = useMemo(() => new Set(
|
const activeCategoryIds = useMemo(() => new Set(catalog.categories.filter((category) => category.isActive).map((category) => category.id)), [catalog.categories]);
|
||||||
catalog.categories.filter((category) => category.isActive).map((category) => category.id),
|
const categoryName = useMemo(() => new Map(catalog.categories.map((category) => [category.id, category.name])), [catalog.categories]);
|
||||||
), [catalog.categories]);
|
|
||||||
|
|
||||||
const categoryName = useMemo(() => new Map(
|
|
||||||
catalog.categories.map((category) => [category.id, category.name]),
|
|
||||||
), [catalog.categories]);
|
|
||||||
|
|
||||||
const available = useMemo(() => {
|
const available = useMemo(() => {
|
||||||
const needle = search.trim().toLocaleLowerCase('es-AR');
|
const needle = search.trim().toLocaleLowerCase('es-AR');
|
||||||
return catalog.items.filter((item) =>
|
return catalog.items.filter((item) => item.isActive && activeCategoryIds.has(item.categoryId)
|
||||||
item.isActive
|
&& (!needle || [item.title, item.code, categoryName.get(item.categoryId) ?? ''].some((value) => value.toLocaleLowerCase('es-AR').includes(needle))));
|
||||||
&& 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]);
|
}, [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'
|
const toggle = (id: string) => setEnabled((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; });
|
||||||
? available.filter((item) => savedIds.has(item.id))
|
const selectVisible = () => setEnabled((current) => { const next = new Set(current); visible.forEach((item) => next.add(item.id)); return next; });
|
||||||
: 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 () => {
|
const save = async () => {
|
||||||
if (!selectedFamily) return;
|
if (!selectedFamily) return;
|
||||||
setSaving(true); setError(''); setSuccess('');
|
setSaving(true); setError(''); setSuccess('');
|
||||||
try {
|
try {
|
||||||
const saved = await replaceInventoryFamilyFindings(selectedFamily.id, {
|
const saved = await replaceInventoryFamilyFindings(selectedFamily.id, { itemIds: [...enabled], reason });
|
||||||
itemIds: [...enabled],
|
const itemIds = saved.items.map((item) => item.id); setEnabled(new Set(itemIds));
|
||||||
reason,
|
setFamilies((current) => current.map((family) => family.id === selectedFamily.id ? { ...family, findingItemIds: itemIds, findingCount: itemIds.length } : family));
|
||||||
});
|
setViewMode('ASSOCIATED'); setSuccess(`Hallazgos guardados para ${selectedFamily.name}.`);
|
||||||
const itemIds = saved.items.map((item) => item.id);
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||||
setEnabled(new Set(itemIds));
|
finally { setSaving(false); }
|
||||||
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 (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>;
|
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 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>
|
||||||
<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>
|
|
||||||
{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>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>
|
||||||
<label className="field">
|
{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>}
|
||||||
<span>Clasificación de Inventario</span>
|
<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>
|
||||||
<SearchableSelect value={familyId} onChange={(event) => setFamilyId(event.target.value)}>
|
{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></>}
|
||||||
{families.map((family) => <option value={family.id} key={family.id}>
|
{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></>}
|
||||||
{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>
|
|
||||||
</>}
|
|
||||||
</section>;
|
</section>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user