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>;
|
||||
}
|
||||
|
||||
@@ -1,163 +1,56 @@
|
||||
import { apiRequest } from './api';
|
||||
|
||||
export type InventoryStructureKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
|
||||
export type InventoryFamilyAttributeDataType = 'TEXT' | 'NUMBER' | 'BOOLEAN' | 'DATE' | 'DATETIME' | 'SELECT';
|
||||
|
||||
export interface InventoryFamilyParent { id:string; code:string; name:string; }
|
||||
export interface InventoryFamily {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
level: 'INSTALLATION' | 'SUBINSTALLATION';
|
||||
legacyTypeCode?: string | null;
|
||||
informationLabels: string[];
|
||||
sourceReference?: string | null;
|
||||
isActive?: boolean;
|
||||
parentFamilyId: string | null;
|
||||
parentFamilyCode: string | null;
|
||||
parentFamilyName: string | null;
|
||||
assetCount?: number;
|
||||
findingCount?: number;
|
||||
findingItemIds?: string[];
|
||||
id:string; code:string; name:string; level:'INSTALLATION'|'SUBINSTALLATION'; legacyTypeCode?:string|null;
|
||||
informationLabels:string[]; sourceReference?:string|null; isActive?:boolean; parentFamilyIds:string[];
|
||||
parentFamilies:InventoryFamilyParent[]; assetCount?:number; findingCount?:number; findingItemIds?:string[]; technicalAttributeCount?:number;
|
||||
}
|
||||
|
||||
export interface InventoryStructureLevel {
|
||||
kind: InventoryStructureKind;
|
||||
label: string;
|
||||
type: { id: string; code: string; name: string };
|
||||
parentKind: InventoryStructureKind | null;
|
||||
requiresFamily: boolean;
|
||||
export interface InventoryFamilyAttribute {
|
||||
id:string; inventoryFamilyId:string; code:string; name:string; dataType:InventoryFamilyAttributeDataType;
|
||||
isRequired:boolean; isActive:boolean; unit:string|null; options:string[]|null; sortOrder:number;
|
||||
}
|
||||
|
||||
export interface InventoryStructureOptions {
|
||||
independentMasters: InventoryStructureLevel[];
|
||||
levels: InventoryStructureLevel[];
|
||||
installationFamilies: InventoryFamily[];
|
||||
subinstallationFamilies: InventoryFamily[];
|
||||
export interface InventoryFamilyAttributes { family:InventoryFamily; items:InventoryFamilyAttribute[]; count:number; }
|
||||
export interface InventoryTechnicalValues {
|
||||
assetId:string;
|
||||
family:{id:string;code:string;name:string;level:string};
|
||||
definitions:InventoryFamilyAttribute[];
|
||||
values:Record<string,unknown>;
|
||||
}
|
||||
|
||||
export interface InventoryStructureLevel { kind:InventoryStructureKind; label:string; type:{id:string;code:string;name:string}; parentKind:InventoryStructureKind|null; requiresFamily:boolean; }
|
||||
export interface InventoryStructureOptions { independentMasters:InventoryStructureLevel[]; levels:InventoryStructureLevel[]; installationFamilies:InventoryFamily[]; subinstallationFamilies:InventoryFamily[]; }
|
||||
export interface InventoryStructureParent {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
commonName: string | null;
|
||||
type: { id: string; code: string; name: string };
|
||||
inventoryFamily: null | {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
level: 'INSTALLATION' | 'SUBINSTALLATION';
|
||||
informationLabels: string[];
|
||||
};
|
||||
parent: null | { id: string; code: string; name: string };
|
||||
id:string; code:string; name:string; commonName:string|null; type:{id:string;code:string;name:string};
|
||||
inventoryFamily:null|{id:string;code:string;name:string;level:'INSTALLATION'|'SUBINSTALLATION';informationLabels:string[]};
|
||||
parent:null|{id:string;code:string;name:string};
|
||||
}
|
||||
|
||||
export interface InventoryFamilyFinding {
|
||||
id: string;
|
||||
code: string;
|
||||
sourceNumber: number;
|
||||
title: string;
|
||||
legalBasis: string | null;
|
||||
glossary: string | null;
|
||||
suggestedSeverity: number | null;
|
||||
categoryId: string;
|
||||
categoryCode: string;
|
||||
categoryName: string;
|
||||
id:string; code:string; sourceNumber:number; title:string; legalBasis:string|null; glossary:string|null;
|
||||
suggestedSeverity:number|null; categoryId:string; categoryCode:string; categoryName:string;
|
||||
}
|
||||
|
||||
export interface InventoryFamilyFindings {
|
||||
family: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
level: string;
|
||||
informationLabels: string[];
|
||||
};
|
||||
items: InventoryFamilyFinding[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface InventoryFamilyFindings { family:InventoryFamily; items:InventoryFamilyFinding[]; count:number; }
|
||||
export interface CreatedInventoryStructure {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
commonName: string | null;
|
||||
description: string | null;
|
||||
informationStatus: string;
|
||||
operationalStatus: string;
|
||||
type: { id: string; code: string; name: string };
|
||||
parent: null | { id: string; code: string; name: string };
|
||||
operationalArea?: null | { id: string; code: string; name: string };
|
||||
inventoryFamily: null | {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
level: string;
|
||||
informationLabels: string[];
|
||||
};
|
||||
id:string; code:string; name:string; commonName:string|null; description:string|null; informationStatus:string; operationalStatus:string;
|
||||
type:{id:string;code:string;name:string}; parent:null|{id:string;code:string;name:string}; operationalArea?:null|{id:string;code:string;name:string};
|
||||
inventoryFamily:null|{id:string;code:string;name:string;level:string;informationLabels:string[]};
|
||||
}
|
||||
|
||||
export function getInventoryStructureOptions() {
|
||||
return apiRequest<InventoryStructureOptions>('/inventory-structure');
|
||||
}
|
||||
|
||||
export async function listInventoryStructureParents(kind: InventoryStructureKind, search = '') {
|
||||
const query = new URLSearchParams();
|
||||
if (search.trim()) query.set('search', search.trim());
|
||||
const suffix = query.size ? `?${query}` : '';
|
||||
return (await apiRequest<{ data: InventoryStructureParent[] }>(
|
||||
`/inventory-structure/parents/${kind}${suffix}`,
|
||||
)).data;
|
||||
}
|
||||
|
||||
export function getInventoryFamilyFindings(familyId: string) {
|
||||
return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`);
|
||||
}
|
||||
|
||||
export async function listInventoryFamiliesAdmin() {
|
||||
return (await apiRequest<{ data: InventoryFamily[] }>('/inventory-families/admin')).data;
|
||||
}
|
||||
|
||||
export function createInventoryFamily(input: {
|
||||
level: 'INSTALLATION' | 'SUBINSTALLATION';
|
||||
name: string;
|
||||
parentFamilyId?: string | null;
|
||||
informationLabels?: string[];
|
||||
}) {
|
||||
return apiRequest<InventoryFamily>('/inventory-families', {
|
||||
method: 'POST', body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateInventoryFamily(familyId: string, input: {
|
||||
name?: string;
|
||||
parentFamilyId?: string | null;
|
||||
informationLabels?: string[];
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
return apiRequest<InventoryFamily>(`/inventory-families/${familyId}`, {
|
||||
method: 'PATCH', body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function replaceInventoryFamilyFindings(familyId: string, input: {
|
||||
itemIds: string[];
|
||||
reason: string;
|
||||
}) {
|
||||
return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`, {
|
||||
method: 'PUT', body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function createInventoryStructure(input: {
|
||||
kind: InventoryStructureKind;
|
||||
code?: string | null;
|
||||
name: string;
|
||||
commonName?: string | null;
|
||||
parentId?: string | null;
|
||||
familyId?: string | null;
|
||||
description?: string | null;
|
||||
}) {
|
||||
return apiRequest<CreatedInventoryStructure>('/inventory-structure', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
export function getInventoryStructureOptions(){return apiRequest<InventoryStructureOptions>('/inventory-structure');}
|
||||
export async function listInventoryStructureParents(kind:InventoryStructureKind,search=''){
|
||||
const query=new URLSearchParams(); if(search.trim()) query.set('search',search.trim()); const suffix=query.size?`?${query}`:'';
|
||||
return (await apiRequest<{data:InventoryStructureParent[]}>(`/inventory-structure/parents/${kind}${suffix}`)).data;
|
||||
}
|
||||
export function getInventoryFamilyFindings(familyId:string){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`);}
|
||||
export function getInventoryFamilyAttributes(familyId:string){return apiRequest<InventoryFamilyAttributes>(`/inventory-families/${familyId}/attributes`);}
|
||||
export function getInventoryTechnicalValues(assetId:string){return apiRequest<InventoryTechnicalValues>(`/assets/${assetId}/technical-values`);}
|
||||
export function replaceInventoryTechnicalValues(assetId:string,values:Record<string,unknown>){return apiRequest<InventoryTechnicalValues>(`/assets/${assetId}/technical-values`,{method:'PUT',body:JSON.stringify({values})});}
|
||||
export async function listInventoryFamiliesAdmin(){return (await apiRequest<{data:InventoryFamily[]}>('/inventory-families/admin')).data;}
|
||||
export function createInventoryFamily(input:{level:'INSTALLATION'|'SUBINSTALLATION';name:string;parentFamilyIds?:string[];informationLabels?:string[]}){return apiRequest<InventoryFamily>('/inventory-families',{method:'POST',body:JSON.stringify(input)});}
|
||||
export function updateInventoryFamily(familyId:string,input:{name?:string;parentFamilyIds?:string[];informationLabels?:string[];isActive?:boolean}){return apiRequest<InventoryFamily>(`/inventory-families/${familyId}`,{method:'PATCH',body:JSON.stringify(input)});}
|
||||
export function createInventoryFamilyAttribute(familyId:string,input:{code:string;name:string;dataType:InventoryFamilyAttributeDataType;isRequired?:boolean;unit?:string|null;options?:string[];sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes`,{method:'POST',body:JSON.stringify(input)});}
|
||||
export function updateInventoryFamilyAttribute(familyId:string,attributeId:string,input:{name?:string;dataType?:InventoryFamilyAttributeDataType;isRequired?:boolean;isActive?:boolean;unit?:string|null;options?:string[]|null;sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes/${attributeId}`,{method:'PATCH',body:JSON.stringify(input)});}
|
||||
export function replaceInventoryFamilyFindings(familyId:string,input:{itemIds:string[];reason:string}){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`,{method:'PUT',body:JSON.stringify(input)});}
|
||||
export function createInventoryStructure(input:{kind:InventoryStructureKind;code?:string|null;name:string;commonName?:string|null;parentId?:string|null;familyId?:string|null;description?:string|null}){return apiRequest<CreatedInventoryStructure>('/inventory-structure',{method:'POST',body:JSON.stringify(input)});}
|
||||
|
||||
+114
-108
@@ -19,11 +19,18 @@ import type {
|
||||
} from '../lib/api';
|
||||
import {
|
||||
createInventoryFamily,
|
||||
createInventoryFamilyAttribute,
|
||||
getInventoryFamilyAttributes,
|
||||
listInventoryFamiliesAdmin,
|
||||
replaceInventoryFamilyFindings,
|
||||
updateInventoryFamily,
|
||||
updateInventoryFamilyAttribute,
|
||||
} from '../lib/inventoryStructureApi';
|
||||
import type {
|
||||
InventoryFamily,
|
||||
InventoryFamilyAttribute,
|
||||
InventoryFamilyAttributeDataType,
|
||||
} from '../lib/inventoryStructureApi';
|
||||
import type { InventoryFamily } from '../lib/inventoryStructureApi';
|
||||
|
||||
const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> = [
|
||||
{ value: 'TEXT', label: 'Texto' },
|
||||
@@ -33,12 +40,12 @@ const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> =
|
||||
{ value: 'DATETIME', label: 'Fecha y hora' },
|
||||
{ value: 'SELECT', label: 'Lista de opciones' },
|
||||
];
|
||||
|
||||
const EMPTY_FINDING_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
|
||||
|
||||
type CanonicalKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
|
||||
type FamilyEditor = InventoryFamily | 'new' | null;
|
||||
type FamilyFindingMode = 'ASSOCIATED' | 'ALL';
|
||||
type TechnicalEditor = InventoryFamilyAttribute | 'new' | null;
|
||||
|
||||
const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string }> = [
|
||||
{ kind: 'EMPRESA', label: 'Empresa', description: 'Maestro independiente. Se vincula temporalmente a un Área.' },
|
||||
@@ -55,11 +62,9 @@ function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | nul
|
||||
const code = kind.toLowerCase();
|
||||
return types.find((type) => type.code.toLowerCase() === code && type.isActive) ?? null;
|
||||
}
|
||||
|
||||
function attributeTypeLabel(value: AssetAttributeDataType) {
|
||||
function attributeTypeLabel(value: AssetAttributeDataType | InventoryFamilyAttributeDataType) {
|
||||
return ATTRIBUTE_TYPES.find((item) => item.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
function attributeCodeFromName(value: string) {
|
||||
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim()
|
||||
.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 80);
|
||||
@@ -68,6 +73,7 @@ function attributeCodeFromName(value: string) {
|
||||
export function AssetTypesPage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canManage = hasPermission('asset_types.manage');
|
||||
const canManageFindings = hasPermission('finding_catalog.manage');
|
||||
const [types, setTypes] = useState<AssetType[]>([]);
|
||||
const [families, setFamilies] = useState<InventoryFamily[]>([]);
|
||||
const [findingCatalog, setFindingCatalog] = useState<FindingAdminCatalog>(EMPTY_FINDING_CATALOG);
|
||||
@@ -81,11 +87,22 @@ export function AssetTypesPage() {
|
||||
const [familyEditor, setFamilyEditor] = useState<FamilyEditor>(null);
|
||||
const [familyLevel, setFamilyLevel] = useState<'INSTALLATION' | 'SUBINSTALLATION'>('INSTALLATION');
|
||||
const [familyName, setFamilyName] = useState('');
|
||||
const [familyParentId, setFamilyParentId] = useState('');
|
||||
const [familyParentIds, setFamilyParentIds] = useState<Set<string>>(new Set());
|
||||
const [familyActive, setFamilyActive] = useState(true);
|
||||
const [familyFindingIds, setFamilyFindingIds] = useState<Set<string>>(new Set());
|
||||
const [familyFindingMode, setFamilyFindingMode] = useState<FamilyFindingMode>('ASSOCIATED');
|
||||
const [familyFindingSearch, setFamilyFindingSearch] = useState('');
|
||||
const [technicalAttributes, setTechnicalAttributes] = useState<InventoryFamilyAttribute[]>([]);
|
||||
const [technicalLoading, setTechnicalLoading] = useState(false);
|
||||
const [technicalEditor, setTechnicalEditor] = useState<TechnicalEditor>(null);
|
||||
const [technicalCode, setTechnicalCode] = useState('');
|
||||
const [technicalName, setTechnicalName] = useState('');
|
||||
const [technicalType, setTechnicalType] = useState<InventoryFamilyAttributeDataType>('TEXT');
|
||||
const [technicalRequired, setTechnicalRequired] = useState(false);
|
||||
const [technicalActive, setTechnicalActive] = useState(true);
|
||||
const [technicalUnit, setTechnicalUnit] = useState('');
|
||||
const [technicalOptions, setTechnicalOptions] = useState('');
|
||||
const [technicalOrder, setTechnicalOrder] = useState(0);
|
||||
|
||||
const [attributeEditor, setAttributeEditor] = useState<AssetAttributeDefinition | 'new' | null>(null);
|
||||
const [attributeCode, setAttributeCode] = useState('');
|
||||
@@ -99,15 +116,10 @@ export function AssetTypesPage() {
|
||||
|
||||
const load = async () => {
|
||||
const [loadedTypes, loadedFamilies, loadedFindingCatalog] = await Promise.all([
|
||||
listAssetTypes(),
|
||||
listInventoryFamiliesAdmin(),
|
||||
getFindingCatalogAdmin(),
|
||||
listAssetTypes(), listInventoryFamiliesAdmin(), getFindingCatalogAdmin(),
|
||||
]);
|
||||
setTypes(loadedTypes);
|
||||
setFamilies(loadedFamilies);
|
||||
setFindingCatalog(loadedFindingCatalog);
|
||||
setTypes(loadedTypes); setFamilies(loadedFamilies); setFindingCatalog(loadedFindingCatalog);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
||||
}, []);
|
||||
@@ -116,7 +128,7 @@ export function AssetTypesPage() {
|
||||
const installationFamilies = useMemo(() => families.filter((item) => item.level === 'INSTALLATION'), [families]);
|
||||
const subinstallationFamilies = useMemo(() => families.filter((item) => item.level === 'SUBINSTALLATION'), [families]);
|
||||
const visibleSubinstallationFamilies = useMemo(() => subinstallationFamilies.filter((family) =>
|
||||
family.isActive !== false && (!selectedInstallationFamilyId || family.parentFamilyId === selectedInstallationFamilyId),
|
||||
family.isActive !== false && (!selectedInstallationFamilyId || family.parentFamilyIds.includes(selectedInstallationFamilyId)),
|
||||
), [subinstallationFamilies, selectedInstallationFamilyId]);
|
||||
|
||||
const activeFindingCategoryIds = useMemo(() => new Set(
|
||||
@@ -138,48 +150,79 @@ export function AssetTypesPage() {
|
||||
|
||||
const openNewFamily = (level: 'INSTALLATION' | 'SUBINSTALLATION') => {
|
||||
setFamilyEditor('new'); setFamilyLevel(level); setFamilyName('');
|
||||
setFamilyParentId(level === 'SUBINSTALLATION' ? selectedInstallationFamilyId : '');
|
||||
setFamilyParentIds(new Set(level === 'SUBINSTALLATION' && selectedInstallationFamilyId ? [selectedInstallationFamilyId] : []));
|
||||
setFamilyActive(true); setFamilyFindingIds(new Set()); setFamilyFindingMode('ASSOCIATED'); setFamilyFindingSearch('');
|
||||
setError(''); setSuccess('');
|
||||
setTechnicalAttributes([]); setTechnicalEditor(null); setError(''); setSuccess('');
|
||||
};
|
||||
const openFamily = (family: InventoryFamily) => {
|
||||
const openFamily = async (family: InventoryFamily) => {
|
||||
if (family.level === 'INSTALLATION') setSelectedInstallationFamilyId(family.id);
|
||||
setFamilyEditor(family); setFamilyLevel(family.level); setFamilyName(family.name);
|
||||
setFamilyParentId(family.parentFamilyId ?? ''); setFamilyActive(family.isActive !== false);
|
||||
setFamilyParentIds(new Set(family.parentFamilyIds)); setFamilyActive(family.isActive !== false);
|
||||
setFamilyFindingIds(new Set(family.findingItemIds ?? [])); setFamilyFindingMode('ASSOCIATED'); setFamilyFindingSearch('');
|
||||
setError(''); setSuccess('');
|
||||
setTechnicalEditor(null); setTechnicalLoading(true); setError(''); setSuccess('');
|
||||
try { setTechnicalAttributes((await getInventoryFamilyAttributes(family.id)).items); }
|
||||
catch (requestError) { setError(errorMessage(requestError)); setTechnicalAttributes([]); }
|
||||
finally { setTechnicalLoading(false); }
|
||||
};
|
||||
|
||||
const toggleParentCompatibility = (id: string) => setFamilyParentIds((current) => {
|
||||
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
|
||||
});
|
||||
const toggleFamilyFinding = (id: string) => setFamilyFindingIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
|
||||
});
|
||||
|
||||
const saveFamily = async (event: FormEvent) => {
|
||||
event.preventDefault(); setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
if (familyEditor === 'new') {
|
||||
await createInventoryFamily({
|
||||
level: familyLevel,
|
||||
name: familyName.trim(),
|
||||
parentFamilyId: familyLevel === 'SUBINSTALLATION' ? familyParentId : null,
|
||||
const created = await createInventoryFamily({
|
||||
level: familyLevel, name: familyName.trim(),
|
||||
parentFamilyIds: familyLevel === 'SUBINSTALLATION' ? [...familyParentIds] : [],
|
||||
});
|
||||
setSuccess(`${familyLevel === 'INSTALLATION' ? 'Tipo de Instalación' : 'Tipo de Subinstalación'} creado.`);
|
||||
} else if (familyEditor) {
|
||||
await load(); await openFamily(created); return;
|
||||
}
|
||||
if (familyEditor) {
|
||||
await updateInventoryFamily(familyEditor.id, {
|
||||
name: familyName.trim(),
|
||||
parentFamilyId: familyLevel === 'SUBINSTALLATION' ? familyParentId : null,
|
||||
name: familyName.trim(), parentFamilyIds: familyLevel === 'SUBINSTALLATION' ? [...familyParentIds] : [],
|
||||
isActive: familyActive,
|
||||
});
|
||||
await replaceInventoryFamilyFindings(familyEditor.id, {
|
||||
itemIds: [...familyFindingIds],
|
||||
reason: 'Actualización desde Configuración de Inventarios',
|
||||
if (canManageFindings) await replaceInventoryFamilyFindings(familyEditor.id, {
|
||||
itemIds: [...familyFindingIds], reason: 'Actualización desde Configuración de Inventarios F6',
|
||||
});
|
||||
setSuccess('Clasificación y Hallazgos asociados actualizados.');
|
||||
setSuccess('Clasificación actualizada.'); await load(); setFamilyEditor(null);
|
||||
}
|
||||
await load();
|
||||
setFamilyEditor(null);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const openTechnicalAttribute = (attribute: InventoryFamilyAttribute | 'new') => {
|
||||
setTechnicalEditor(attribute); setError(''); setSuccess('');
|
||||
if (attribute === 'new') {
|
||||
setTechnicalCode(''); setTechnicalName(''); setTechnicalType('TEXT'); setTechnicalRequired(false);
|
||||
setTechnicalActive(true); setTechnicalUnit(''); setTechnicalOptions(''); setTechnicalOrder(technicalAttributes.length);
|
||||
} else {
|
||||
setTechnicalCode(attribute.code); setTechnicalName(attribute.name); setTechnicalType(attribute.dataType);
|
||||
setTechnicalRequired(attribute.isRequired); setTechnicalActive(attribute.isActive); setTechnicalUnit(attribute.unit ?? '');
|
||||
setTechnicalOptions(attribute.options?.join('\n') ?? ''); setTechnicalOrder(attribute.sortOrder);
|
||||
}
|
||||
};
|
||||
const saveTechnicalAttribute = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!familyEditor || familyEditor === 'new' || !technicalEditor) return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
const options = technicalOptions.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
|
||||
try {
|
||||
if (technicalEditor === 'new') await createInventoryFamilyAttribute(familyEditor.id, {
|
||||
code: technicalCode, name: technicalName.trim(), dataType: technicalType, isRequired: technicalRequired,
|
||||
unit: technicalUnit || null, ...(technicalType === 'SELECT' ? { options } : {}), sortOrder: technicalOrder,
|
||||
});
|
||||
else await updateInventoryFamilyAttribute(familyEditor.id,technicalEditor.id,{
|
||||
name:technicalName.trim(),dataType:technicalType,isRequired:technicalRequired,isActive:technicalActive,
|
||||
unit:technicalUnit || null,options:technicalType==='SELECT' ? options : null,sortOrder:technicalOrder,
|
||||
});
|
||||
setTechnicalAttributes((await getInventoryFamilyAttributes(familyEditor.id)).items);
|
||||
setTechnicalEditor(null); setSuccess('Campo técnico actualizado.'); await load();
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
@@ -188,100 +231,63 @@ export function AssetTypesPage() {
|
||||
setAttributeEditor(attribute); setError(''); setSuccess('');
|
||||
if (attribute === 'new') {
|
||||
setAttributeCode(''); setAttributeName(''); setAttributeType('TEXT'); setAttributeRequired(false);
|
||||
setAttributeActive(true); setAttributeUnit(''); setAttributeOptions('');
|
||||
setAttributeOrder(selectedType?.attributes.length ?? 0);
|
||||
setAttributeActive(true); setAttributeUnit(''); setAttributeOptions(''); setAttributeOrder(selectedType?.attributes.length ?? 0);
|
||||
} else {
|
||||
setAttributeCode(attribute.code); setAttributeName(attribute.name); setAttributeType(attribute.dataType);
|
||||
setAttributeRequired(attribute.isRequired); setAttributeActive(attribute.isActive);
|
||||
setAttributeUnit(attribute.unit ?? ''); setAttributeOptions(attribute.options?.join('\n') ?? '');
|
||||
setAttributeOrder(attribute.sortOrder);
|
||||
setAttributeRequired(attribute.isRequired); setAttributeActive(attribute.isActive); setAttributeUnit(attribute.unit ?? '');
|
||||
setAttributeOptions(attribute.options?.join('\n') ?? ''); setAttributeOrder(attribute.sortOrder);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAttribute = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedType || !attributeEditor) return;
|
||||
event.preventDefault(); if (!selectedType || !attributeEditor) return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
const options = attributeOptions.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
|
||||
try {
|
||||
if (attributeEditor === 'new') {
|
||||
await createAssetAttribute(selectedType.id, {
|
||||
code: attributeCode,
|
||||
name: attributeName,
|
||||
dataType: attributeType,
|
||||
isRequired: attributeRequired,
|
||||
unit: attributeUnit || null,
|
||||
...(attributeType === 'SELECT' ? { options } : {}),
|
||||
sortOrder: attributeOrder,
|
||||
});
|
||||
setSuccess(`Nueva columna agregada a ${LEVELS.find((item) => item.kind === selectedKind)?.label}.`);
|
||||
} else {
|
||||
await updateAssetAttribute(selectedType.id, attributeEditor.id, {
|
||||
name: attributeName,
|
||||
dataType: attributeType,
|
||||
isRequired: attributeRequired,
|
||||
isActive: attributeActive,
|
||||
unit: attributeUnit || null,
|
||||
options: attributeType === 'SELECT' ? options : null,
|
||||
sortOrder: attributeOrder,
|
||||
});
|
||||
setSuccess('Columna actualizada.');
|
||||
}
|
||||
await load();
|
||||
setAttributeEditor(null);
|
||||
if (attributeEditor === 'new') await createAssetAttribute(selectedType.id, {
|
||||
code: attributeCode,name: attributeName,dataType: attributeType,isRequired: attributeRequired,
|
||||
unit: attributeUnit || null,...(attributeType === 'SELECT' ? { options } : {}),sortOrder: attributeOrder,
|
||||
});
|
||||
else await updateAssetAttribute(selectedType.id, attributeEditor.id, {
|
||||
name: attributeName,dataType: attributeType,isRequired: attributeRequired,isActive: attributeActive,
|
||||
unit: attributeUnit || null,options: attributeType === 'SELECT' ? options : null,sortOrder: attributeOrder,
|
||||
});
|
||||
await load(); setAttributeEditor(null); setSuccess('Campo general actualizado.');
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando configuración de Inventarios…" />;
|
||||
|
||||
return <section className="inventory-config-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<span className="eyebrow">ADMINISTRACIÓN</span>
|
||||
<h1>Configuración de Inventarios</h1>
|
||||
<p>Administrá la estructura, los tipos de Instalación/Subinstalación, sus Hallazgos y las columnas de información.</p>
|
||||
</div>
|
||||
{canManage && <Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>}
|
||||
</div>
|
||||
<div className="page-heading"><div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Configuración de Inventarios</h1><p>Administrá clasificaciones, compatibilidades, Hallazgos y campos técnicos desde un único lugar.</p></div>{canManage && <Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>}</div>
|
||||
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
||||
|
||||
<div className="panel" style={{ marginBottom: 18 }}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">MODELO VIGENTE</span><h2>Estructura física</h2><p className="section-copy">Cada nivel tiene un único padre estructural. Empresa queda como maestro independiente y se relaciona con Área.</p></div></div>
|
||||
<div className="asset-browser-levels" aria-label="Estructura de Inventarios">
|
||||
<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>
|
||||
<div className="temporal-notice" style={{ marginTop: 14 }}><Icon name="users" /><p><strong>Empresa:</strong> maestro independiente. Cambiar la operadora de un Área no mueve ni reescribe la estructura física.</p></div>
|
||||
</div>
|
||||
<div className="panel" style={{ marginBottom: 18 }}><div className="panel-heading"><div><span className="eyebrow">MODELO F6</span><h2>Estructura física fija</h2><p className="section-copy">La clasificación describe qué es cada Instalación/Subinstalación, pero nunca altera su nivel jerárquico.</p></div></div><div className="asset-browser-levels" aria-label="Estructura de Inventarios"><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><div className="temporal-notice" style={{ marginTop: 14 }}><Icon name="users" /><p><strong>Empresa:</strong> maestro independiente; su relación con Área no reescribe el árbol físico.</p></div></div>
|
||||
|
||||
<div className="dashboard-grid" style={{ alignItems: 'start' }}>
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Instalación</h2><p className="section-copy">Tocá una Instalación para filtrar sus Subinstalaciones y editar sus Hallazgos.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('INSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div>
|
||||
<div className="attribute-list">{installationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className={`attribute-card ${selectedInstallationFamilyId === family.id ? 'active' : ''}`} key={family.id} onClick={() => openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.findingCount ?? 0} Hallazgos asociados</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>
|
||||
</article>
|
||||
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Subinstalación</h2><p className="section-copy">Mostrá todas o sólo las que pertenecen a una Instalación.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('SUBINSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div>
|
||||
<label className="field" style={{ marginBottom: 14 }}><span>Filtrar por tipo de Instalación</span><SearchableSelect value={selectedInstallationFamilyId} onChange={(event) => setSelectedInstallationFamilyId(event.target.value)}><option value="">Todas las Instalaciones</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}</SearchableSelect></label>
|
||||
{visibleSubinstallationFamilies.length === 0 ? <div className="inline-empty">{selectedInstallationFamilyId ? 'Esta Instalación todavía no tiene tipos de Subinstalación asociados.' : 'No hay tipos de Subinstalación configurados.'}</div> : <div className="attribute-list" style={{ maxHeight: 560, overflow: 'auto' }}>{visibleSubinstallationFamilies.map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilyName ?? 'Sin Instalación padre'} · {family.findingCount ?? 0} Hallazgos asociados</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>}
|
||||
</article>
|
||||
<article className="panel"><div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Instalación</h2><p className="section-copy">Tocá uno para editar Hallazgos, campos técnicos y ver sus Subinstalaciones compatibles.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('INSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div><div className="attribute-list">{installationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className={`attribute-card ${selectedInstallationFamilyId === family.id ? 'active' : ''}`} key={family.id} onClick={() => void openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.findingCount ?? 0} Hallazgos · {family.technicalAttributeCount ?? 0} campos técnicos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div></article>
|
||||
<article className="panel"><div className="panel-heading"><div><span className="eyebrow">COMPATIBILIDAD</span><h2>Tipos de Subinstalación</h2><p className="section-copy">Una misma clasificación puede ser válida para varias Instalaciones.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('SUBINSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div><label className="field" style={{ marginBottom: 14 }}><span>Filtrar por tipo de Instalación</span><SearchableSelect value={selectedInstallationFamilyId} onChange={(event) => setSelectedInstallationFamilyId(event.target.value)}><option value="">Todas las Instalaciones</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}</SearchableSelect></label>{visibleSubinstallationFamilies.length === 0 ? <div className="inline-empty">{selectedInstallationFamilyId ? 'No hay tipos compatibles con esta Instalación.' : 'No hay tipos de Subinstalación configurados.'}</div> : <div className="attribute-list" style={{ maxHeight: 560, overflow: 'auto' }}>{visibleSubinstallationFamilies.map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => void openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilies.map((parent) => parent.name).join(' · ') || 'Sin compatibilidades'} · {family.findingCount ?? 0} Hallazgos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>}</article>
|
||||
</div>
|
||||
|
||||
<article className="panel" style={{ marginTop: 18 }}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">COLUMNAS / CAMPOS</span><h2>Información configurable</h2><p className="section-copy">Agregá las columnas que deben completarse para cada nivel. No modifica la jerarquía.</p></div>{canManage && selectedType && <button className="button primary" onClick={() => openAttribute('new')}><Icon name="plus" />Nueva columna</button>}</div>
|
||||
<div className="quick-view-row" style={{ marginBottom: 16 }}>{LEVELS.map((level) => <button type="button" key={level.kind} className={selectedKind === level.kind ? 'active' : ''} onClick={() => { setSelectedKind(level.kind); setAttributeEditor(null); }}>{level.label}</button>)}</div>
|
||||
<p className="section-copy">{LEVELS.find((item) => item.kind === selectedKind)?.description}</p>
|
||||
{!selectedType ? <Alert>Este nivel todavía no tiene un tipo maestro activo.</Alert> : selectedType.attributes.length === 0 ? <div className="inline-empty">No hay columnas adicionales configuradas para {LEVELS.find((item) => item.kind === selectedKind)?.label}.</div> : <div className="attribute-list">{selectedType.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>}
|
||||
</article>
|
||||
<article className="panel" style={{ marginTop: 18 }}><div className="panel-heading"><div><span className="eyebrow">CAMPOS GENERALES DEL NIVEL</span><h2>Información estructural</h2><p className="section-copy">Usá estos campos sólo cuando correspondan a todo el nivel. Marca, potencia, capacidad u otros datos técnicos deben configurarse dentro de cada clasificación.</p></div>{canManage && selectedType && <button className="button primary" onClick={() => openAttribute('new')}><Icon name="plus" />Nuevo campo general</button>}</div><div className="quick-view-row" style={{ marginBottom: 16 }}>{LEVELS.map((level) => <button type="button" key={level.kind} className={selectedKind === level.kind ? 'active' : ''} onClick={() => { setSelectedKind(level.kind); setAttributeEditor(null); }}>{level.label}</button>)}</div><p className="section-copy">{LEVELS.find((item) => item.kind === selectedKind)?.description}</p>{!selectedType ? <Alert>Este nivel todavía no tiene un tipo maestro activo.</Alert> : selectedType.attributes.length === 0 ? <div className="inline-empty">No hay campos generales para este nivel.</div> : <div className="attribute-list">{selectedType.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>}</article>
|
||||
|
||||
<div className="panel" style={{ marginTop: 18 }}><div className="panel-heading"><div><span className="eyebrow">HALLAZGOS</span><h2>Relación por clasificación</h2><p className="section-copy">Los Hallazgos se pueden revisar y editar directamente dentro de cada tipo. El Catálogo completo sigue disponible para una administración masiva.</p></div><Link className="button secondary" to="/admin/finding-catalog">Abrir Catálogo de hallazgos <Icon name="chevron" /></Link></div></div>
|
||||
{familyEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveFamily}><div className="drawer-heading"><div><span className="eyebrow">CLASIFICACIÓN TÉCNICA</span><h2>{familyEditor === 'new' ? 'Nueva clasificación' : familyName}</h2></div><button type="button" className="icon-button" onClick={() => setFamilyEditor(null)}>×</button></div><div className="catalog-editor-fields">
|
||||
{familyEditor === 'new' && <label className="field"><span>Nivel</span><SearchableSelect value={familyLevel} onChange={(event) => { setFamilyLevel(event.target.value as 'INSTALLATION' | 'SUBINSTALLATION'); setFamilyParentIds(new Set()); }}><option value="INSTALLATION">Instalación</option><option value="SUBINSTALLATION">Subinstalación</option></SearchableSelect></label>}
|
||||
<label className="field"><span>Nombre</span><input value={familyName} onChange={(event) => setFamilyName(event.target.value)} maxLength={240} required /></label>
|
||||
{familyLevel === 'SUBINSTALLATION' && <div className="field"><span>Tipos de Instalación compatibles <em>{familyParentIds.size} seleccionados</em></span><div className="finding-selection-list" style={{ maxHeight: 220, overflow: 'auto' }}>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label className="finding-selection-row" key={family.id}><input type="checkbox" checked={familyParentIds.has(family.id)} onChange={() => toggleParentCompatibility(family.id)} /><span><strong>{family.name}</strong><small>{family.code}</small></span></label>)}</div></div>}
|
||||
{familyEditor !== 'new' && <>
|
||||
<div className="temporal-notice"><Icon name="alert" /><p><strong>{familyFindingIds.size} Hallazgos asociados.</strong> La APK sólo verá estos Hallazgos para esta clasificación, además de OTROS.</p></div>
|
||||
<div className="quick-view-row"><button type="button" className={familyFindingMode === 'ASSOCIATED' ? 'active' : ''} onClick={() => setFamilyFindingMode('ASSOCIATED')}>Asociados ({familyFindingIds.size})</button>{canManageFindings && <button type="button" className={familyFindingMode === 'ALL' ? 'active' : ''} onClick={() => setFamilyFindingMode('ALL')}>Agregar o quitar</button>}</div>
|
||||
<label className="field"><span>Buscar Hallazgo</span><input value={familyFindingSearch} onChange={(event) => setFamilyFindingSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>
|
||||
{visibleFamilyFindings.length === 0 ? <div className="inline-empty">{familyFindingMode === 'ASSOCIATED' ? 'No hay Hallazgos asociados.' : 'No hay coincidencias.'}</div> : <div className="finding-selection-list" style={{ maxHeight: 280, overflow: 'auto' }}>{visibleFamilyFindings.map((item) => familyFindingMode === 'ALL' ? <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={familyFindingIds.has(item.id)} onChange={() => toggleFamilyFinding(item.id)} /><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</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>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></div>)}</div>}
|
||||
|
||||
{familyEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveFamily}><div className="drawer-heading"><div><span className="eyebrow">CLASIFICACIÓN DE INVENTARIO</span><h2>{familyEditor === 'new' ? 'Nuevo tipo' : 'Editar tipo'}</h2></div><button type="button" className="icon-button" onClick={() => setFamilyEditor(null)}>×</button></div><div className="catalog-editor-fields">{familyEditor === 'new' && <label className="field"><span>Nivel</span><SearchableSelect value={familyLevel} onChange={(event) => { setFamilyLevel(event.target.value as 'INSTALLATION' | 'SUBINSTALLATION'); setFamilyParentId(''); }}><option value="INSTALLATION">Instalación</option><option value="SUBINSTALLATION">Subinstalación</option></SearchableSelect></label>}<label className="field"><span>Nombre</span><input value={familyName} onChange={(event) => setFamilyName(event.target.value)} maxLength={240} required placeholder={familyLevel === 'INSTALLATION' ? 'Ej.: Estación, Planta…' : 'Ej.: Tanque, Bomba…'} /></label>{familyLevel === 'SUBINSTALLATION' && <label className="field"><span>Tipo de Instalación padre</span><SearchableSelect value={familyParentId} onChange={(event) => setFamilyParentId(event.target.value)} required><option value="">Seleccionar…</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option value={family.id} key={family.id}>{family.name}</option>)}</SearchableSelect></label>}{familyEditor !== 'new' && <><div className="temporal-notice"><Icon name="alert" /><p><strong>{familyFindingIds.size} Hallazgos asociados.</strong> Se guardan junto con esta clasificación.</p></div><div className="quick-view-row"><button type="button" className={familyFindingMode === 'ASSOCIATED' ? 'active' : ''} onClick={() => setFamilyFindingMode('ASSOCIATED')}>Asociados ({familyFindingIds.size})</button><button type="button" className={familyFindingMode === 'ALL' ? 'active' : ''} onClick={() => setFamilyFindingMode('ALL')}>Agregar o quitar</button></div><label className="field"><span>Buscar Hallazgo</span><input value={familyFindingSearch} onChange={(event) => setFamilyFindingSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>{visibleFamilyFindings.length === 0 ? <div className="inline-empty">{familyFindingMode === 'ASSOCIATED' ? 'No hay Hallazgos asociados. Tocá “Agregar o quitar” para vincularlos.' : 'No hay Hallazgos que coincidan con la búsqueda.'}</div> : <div className="finding-selection-list" style={{ maxHeight: 300, overflow: 'auto' }}>{visibleFamilyFindings.map((item) => familyFindingMode === 'ALL' ? <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={familyFindingIds.has(item.id)} onChange={() => toggleFamilyFinding(item.id)} /><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</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>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></div>)}</div>}<Link className="button secondary" to={`/admin/finding-catalog?familyId=${familyEditor.id}`}>Abrir en Catálogo completo <Icon name="chevron" /></Link><label className="check-row"><input type="checkbox" checked={familyActive} onChange={(event) => setFamilyActive(event.target.checked)} /><span><strong>Tipo disponible</strong><small>Al desactivarlo deja de ofrecerse en nuevas altas.</small></span></label></>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setFamilyEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !familyName.trim() || (familyLevel === 'SUBINSTALLATION' && !familyParentId)}>{saving ? 'Guardando…' : 'Guardar tipo y Hallazgos'}</button></div></form></div>}
|
||||
<div className="panel-heading" style={{ marginTop: 10 }}><div><span className="eyebrow">DATOS DEL RUBRO</span><h3>Campos técnicos</h3><p className="section-copy">Sólo aparecen cuando un elemento usa esta clasificación.</p></div>{canManage && !technicalEditor && <button type="button" className="button secondary" onClick={() => openTechnicalAttribute('new')}><Icon name="plus" />Nuevo campo</button>}</div>
|
||||
{technicalLoading ? <LoadingBlock label="Cargando campos técnicos…" /> : technicalAttributes.length === 0 && !technicalEditor ? <div className="inline-empty">No hay campos técnicos definidos.</div> : !technicalEditor && <div className="attribute-list">{technicalAttributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openTechnicalAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span>{attribute.isRequired && <span className="tag">Obligatorio</span>}<Icon name="chevron" /></button>)}</div>}
|
||||
{technicalEditor && <div className="panel" style={{ padding: 14 }}><div className="panel-heading"><div><strong>{technicalEditor === 'new' ? 'Nuevo campo técnico' : 'Editar campo técnico'}</strong></div><button type="button" className="icon-button" onClick={() => setTechnicalEditor(null)}>×</button></div><label className="field"><span>Nombre</span><input value={technicalName} onChange={(event) => { setTechnicalName(event.target.value); if (technicalEditor === 'new') setTechnicalCode(attributeCodeFromName(event.target.value)); }} required /></label><label className="field"><span>Código interno</span><input value={technicalCode} onChange={(event) => setTechnicalCode(event.target.value.toLowerCase())} disabled={technicalEditor !== 'new'} pattern="[a-z][a-z0-9_]*" required /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={technicalType} onChange={(event) => setTechnicalType(event.target.value as InventoryFamilyAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>{technicalType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={4} value={technicalOptions} onChange={(event) => setTechnicalOptions(event.target.value)} placeholder="Una por línea" required /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={technicalUnit} onChange={(event) => setTechnicalUnit(event.target.value)} /></label><label className="field"><span>Orden</span><input type="number" min={0} max={10000} value={technicalOrder} onChange={(event) => setTechnicalOrder(Number(event.target.value))} /></label><label className="check-row"><input type="checkbox" checked={technicalRequired} onChange={(event) => setTechnicalRequired(event.target.checked)} /><span><strong>Obligatorio</strong><small>Debe completarse para esta clasificación.</small></span></label>{technicalEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={technicalActive} onChange={(event) => setTechnicalActive(event.target.checked)} /><span><strong>Campo activo</strong><small>Desactivarlo conserva datos existentes.</small></span></label>}<div className="form-actions"><button type="button" className="button secondary" onClick={() => setTechnicalEditor(null)}>Cancelar</button><button type="button" className="button primary" disabled={saving || !technicalName.trim() || !technicalCode || (technicalType === 'SELECT' && !technicalOptions.trim())} onClick={(event) => void saveTechnicalAttribute(event as unknown as FormEvent)}>{saving ? 'Guardando…' : 'Guardar campo'}</button></div></div>}
|
||||
<Link className="button secondary" to={`/admin/finding-catalog?familyId=${familyEditor.id}`}>Abrir Catálogo completo <Icon name="chevron" /></Link>
|
||||
<label className="check-row"><input type="checkbox" checked={familyActive} onChange={(event) => setFamilyActive(event.target.checked)} /><span><strong>Clasificación disponible</strong><small>Al desactivarla deja de ofrecerse en nuevas altas.</small></span></label>
|
||||
</>}
|
||||
</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setFamilyEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !familyName.trim() || (familyLevel === 'SUBINSTALLATION' && familyParentIds.size === 0)}>{saving ? 'Guardando…' : familyEditor === 'new' ? 'Crear clasificación' : 'Guardar clasificación'}</button></div></form></div>}
|
||||
|
||||
{attributeEditor && selectedType && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveAttribute}><div className="drawer-heading"><div><span className="eyebrow">COLUMNA DE {LEVELS.find((item) => item.kind === selectedKind)?.label.toUpperCase()}</span><h2>{attributeEditor === 'new' ? 'Nueva columna' : 'Editar columna'}</h2></div><button type="button" className="icon-button" onClick={() => setAttributeEditor(null)}>×</button></div><div className="catalog-editor-fields"><label className="field"><span>Nombre visible</span><input value={attributeName} onChange={(event) => { setAttributeName(event.target.value); if (attributeEditor === 'new') setAttributeCode(attributeCodeFromName(event.target.value)); }} maxLength={160} required /></label><label className="field"><span>Código interno</span><input value={attributeCode} onChange={(event) => setAttributeCode(event.target.value.toLowerCase())} disabled={attributeEditor !== 'new'} maxLength={80} required pattern="[a-z][a-z0-9_]*" /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={attributeType} onChange={(event) => setAttributeType(event.target.value as AssetAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}</SearchableSelect></label>{attributeType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={5} value={attributeOptions} onChange={(event) => setAttributeOptions(event.target.value)} placeholder="Una opción por línea" /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={attributeUnit} onChange={(event) => setAttributeUnit(event.target.value)} maxLength={40} /></label><label className="field"><span>Orden</span><input type="number" value={attributeOrder} onChange={(event) => setAttributeOrder(Number(event.target.value))} min={0} max={10000} /></label><label className="check-row"><input type="checkbox" checked={attributeRequired} onChange={(event) => setAttributeRequired(event.target.checked)} /><span><strong>Campo obligatorio</strong><small>Debe completarse cuando se registra este nivel.</small></span></label>{attributeEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={attributeActive} onChange={(event) => setAttributeActive(event.target.checked)} /><span><strong>Columna activa</strong><small>Desactivarla conserva los datos actuales.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setAttributeEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !attributeName.trim() || !attributeCode}>{saving ? 'Guardando…' : 'Guardar columna'}</button></div></form></div>}
|
||||
{attributeEditor && selectedType && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveAttribute}><div className="drawer-heading"><div><span className="eyebrow">CAMPO GENERAL DE {LEVELS.find((item) => item.kind === selectedKind)?.label.toUpperCase()}</span><h2>{attributeEditor === 'new' ? 'Nuevo campo' : 'Editar campo'}</h2></div><button type="button" className="icon-button" onClick={() => setAttributeEditor(null)}>×</button></div><div className="catalog-editor-fields"><label className="field"><span>Nombre visible</span><input value={attributeName} onChange={(event) => { setAttributeName(event.target.value); if (attributeEditor === 'new') setAttributeCode(attributeCodeFromName(event.target.value)); }} maxLength={160} required /></label><label className="field"><span>Código interno</span><input value={attributeCode} onChange={(event) => setAttributeCode(event.target.value.toLowerCase())} disabled={attributeEditor !== 'new'} maxLength={80} required pattern="[a-z][a-z0-9_]*" /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={attributeType} onChange={(event) => setAttributeType(event.target.value as AssetAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}</SearchableSelect></label>{attributeType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={5} value={attributeOptions} onChange={(event) => setAttributeOptions(event.target.value)} placeholder="Una opción por línea" /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={attributeUnit} onChange={(event) => setAttributeUnit(event.target.value)} maxLength={40} /></label><label className="field"><span>Orden</span><input type="number" value={attributeOrder} onChange={(event) => setAttributeOrder(Number(event.target.value))} min={0} max={10000} /></label><label className="check-row"><input type="checkbox" checked={attributeRequired} onChange={(event) => setAttributeRequired(event.target.checked)} /><span><strong>Campo obligatorio</strong><small>Debe completarse cuando se registra este nivel.</small></span></label>{attributeEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={attributeActive} onChange={(event) => setAttributeActive(event.target.checked)} /><span><strong>Campo activo</strong><small>Desactivarlo conserva los datos actuales.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setAttributeEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !attributeName.trim() || !attributeCode}>{saving ? 'Guardando…' : 'Guardar campo'}</button></div></form></div>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -26,24 +26,13 @@ const KINDS: Array<{ kind: InventoryStructureKind; label: string }> = [
|
||||
{ kind: 'SUBINSTALACION', label: 'Subinstalación' },
|
||||
{ kind: 'EMPRESA', label: 'Empresa' },
|
||||
];
|
||||
|
||||
const childKindByParentType: Record<string, InventoryStructureKind | undefined> = {
|
||||
departamento: 'AREA',
|
||||
area: 'YACIMIENTO',
|
||||
yacimiento: 'INSTALACION',
|
||||
instalacion: 'SUBINSTALACION',
|
||||
departamento: 'AREA', area: 'YACIMIENTO', yacimiento: 'INSTALACION', instalacion: 'SUBINSTALACION',
|
||||
};
|
||||
|
||||
const parentLabelByKind: Partial<Record<InventoryStructureKind,string>> = {
|
||||
AREA:'Departamento',
|
||||
YACIMIENTO:'Área',
|
||||
INSTALACION:'Yacimiento',
|
||||
SUBINSTALACION:'Instalación',
|
||||
AREA:'Departamento', YACIMIENTO:'Área', INSTALACION:'Yacimiento', SUBINSTALACION:'Instalación',
|
||||
};
|
||||
|
||||
function kindLabel(kind: InventoryStructureKind) {
|
||||
return KINDS.find((item) => item.kind===kind)?.label ?? kind;
|
||||
}
|
||||
function kindLabel(kind: InventoryStructureKind) { return KINDS.find((item) => item.kind===kind)?.label ?? kind; }
|
||||
|
||||
export function InventoryCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -65,17 +54,14 @@ export function InventoryCreatePage() {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getInventoryStructureOptions()
|
||||
.then(async (loaded) => {
|
||||
setOptions(loaded);
|
||||
if (contextParentId) {
|
||||
const parent = await getAsset(contextParentId);
|
||||
const inferred = childKindByParentType[parent.type.code.toLowerCase()];
|
||||
if (inferred) { setKind(inferred); setParentId(parent.id); }
|
||||
}
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
getInventoryStructureOptions().then(async (loaded) => {
|
||||
setOptions(loaded);
|
||||
if (contextParentId) {
|
||||
const parent = await getAsset(contextParentId);
|
||||
const inferred = childKindByParentType[parent.type.code.toLowerCase()];
|
||||
if (inferred) { setKind(inferred); setParentId(parent.id); }
|
||||
}
|
||||
}).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
||||
}, [contextParentId]);
|
||||
|
||||
const requiresParent = Boolean(parentLabelByKind[kind]);
|
||||
@@ -85,12 +71,10 @@ export function InventoryCreatePage() {
|
||||
useEffect(() => {
|
||||
if (!requiresParent) { setParents([]); setParentId(''); return; }
|
||||
const timer = window.setTimeout(() => {
|
||||
listInventoryStructureParents(kind,parentSearch)
|
||||
.then((loaded) => {
|
||||
setParents(loaded);
|
||||
if (contextParentId && loaded.some((item) => item.id===contextParentId)) setParentId(contextParentId);
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)));
|
||||
listInventoryStructureParents(kind,parentSearch).then((loaded) => {
|
||||
setParents(loaded);
|
||||
if (contextParentId && loaded.some((item) => item.id===contextParentId)) setParentId(contextParentId);
|
||||
}).catch((requestError) => setError(errorMessage(requestError)));
|
||||
},150);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [kind,parentSearch,contextParentId,requiresParent]);
|
||||
@@ -101,7 +85,7 @@ export function InventoryCreatePage() {
|
||||
if (kind==='INSTALACION') return options.installationFamilies;
|
||||
if (kind==='SUBINSTALACION') {
|
||||
const parentFamilyId=selectedParent?.inventoryFamily?.id;
|
||||
return parentFamilyId ? options.subinstallationFamilies.filter((item)=>item.parentFamilyId===parentFamilyId) : [];
|
||||
return parentFamilyId ? options.subinstallationFamilies.filter((item)=>item.parentFamilyIds.includes(parentFamilyId)) : [];
|
||||
}
|
||||
return [];
|
||||
},[options,kind,selectedParent]);
|
||||
@@ -111,7 +95,6 @@ export function InventoryCreatePage() {
|
||||
if (!familyId) { setFamilyFindings(null); return; }
|
||||
getInventoryFamilyFindings(familyId).then(setFamilyFindings).catch((requestError)=>setError(errorMessage(requestError)));
|
||||
},[familyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requiresFamily) setFamilyId('');
|
||||
if (kind==='SUBINSTALACION' && familyId && !families.some((item)=>item.id===familyId)) setFamilyId('');
|
||||
@@ -120,7 +103,6 @@ export function InventoryCreatePage() {
|
||||
const changeKind=(next:InventoryStructureKind) => {
|
||||
setKind(next); setParentId(''); setParentSearch(''); setFamilyId(''); setFamilyFindings(null); setError('');
|
||||
};
|
||||
|
||||
const save=async(event:FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (requiresParent && !parentId) { setError(`Seleccioná ${parentLabel}.`); return; }
|
||||
@@ -137,46 +119,15 @@ export function InventoryCreatePage() {
|
||||
};
|
||||
|
||||
if (loading) return <LoadingBlock label="Preparando alta…" />;
|
||||
|
||||
return <section className="narrow-section asset-detail-page">
|
||||
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del Inventario"><Link to="/inventarios">Inventarios</Link><span>›</span><strong>Nuevo registro</strong></nav>
|
||||
<div className="page-heading asset-editor-heading">
|
||||
<div><span className="eyebrow">CARGA MANUAL</span><h1>Nuevo registro</h1><p>Elegí qué querés crear, dónde va y su nombre. El resto es opcional.</p></div>
|
||||
</div>
|
||||
<div className="page-heading asset-editor-heading"><div><span className="eyebrow">CARGA MANUAL</span><h1>Nuevo registro</h1><p>Para crear sólo necesitás ubicación, clasificación cuando corresponda y nombre. DH genera el código automáticamente.</p></div></div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<form className="panel form-panel" onSubmit={save}>
|
||||
<div className="form-section">
|
||||
<div><h2>1. Tipo de registro</h2><p className="section-copy">La estructura física es Departamento → Área → Yacimiento → Instalación → Subinstalación. Empresa es un maestro independiente.</p></div>
|
||||
<label className="field"><span>Crear</span><select value={kind} onChange={(event)=>changeKind(event.target.value as InventoryStructureKind)}>{KINDS.map((item)=><option key={item.kind} value={item.kind}>{item.label}</option>)}</select></label>
|
||||
</div>
|
||||
|
||||
{requiresParent && <div className="form-section">
|
||||
<div><h2>2. Ubicación</h2><p className="section-copy">Elegí el {parentLabel.toLowerCase()} al que pertenece.</p></div>
|
||||
<label className="field"><span>Buscar {parentLabel.toLowerCase()}</span><input value={parentSearch} onChange={(event)=>setParentSearch(event.target.value)} placeholder={`Buscar ${parentLabel.toLowerCase()}…`} /></label>
|
||||
<label className="field"><span>{parentLabel} <em>obligatorio</em></span><select value={parentId} onChange={(event)=>{setParentId(event.target.value);setFamilyId('');}} required><option value="">Seleccionar…</option>{parents.map((parent)=><option key={parent.id} value={parent.id}>{parent.name} · {parent.code}</option>)}</select></label>
|
||||
</div>}
|
||||
|
||||
{requiresFamily && <div className="form-section">
|
||||
<div><h2>{requiresParent ? '3' : '2'}. Clasificación</h2><p className="section-copy">Define qué tipo de elemento es y qué Hallazgos le corresponden.</p></div>
|
||||
{kind==='SUBINSTALACION' && !selectedParent?.inventoryFamily
|
||||
? <Alert>La Instalación elegida todavía no tiene clasificación. Asignala antes de crear una Subinstalación.</Alert>
|
||||
: <label className="field"><span>Tipo de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span><select value={familyId} onChange={(event)=>setFamilyId(event.target.value)} required><option value="">Seleccionar…</option>{families.map((family)=><option key={family.id} value={family.id}>{family.name}</option>)}</select></label>}
|
||||
{selectedFamily && <div className="context-create-banner"><Icon name="alert" /><div><strong>{selectedFamily.name}</strong><span>{familyFindings ? `${familyFindings.count} Hallazgo${familyFindings.count===1?'':'s'} asociado${familyFindings.count===1?'':'s'}` : 'Cargando Hallazgos asociados…'}</span>{familyFindings && familyFindings.items.length>0 && <ul style={{margin:'8px 0 0',paddingLeft:18}}>{familyFindings.items.slice(0,6).map((item)=><li key={item.id}>{item.title}</li>)}{familyFindings.items.length>6 && <li>+ {familyFindings.items.length-6} más</li>}</ul>}</div></div>}
|
||||
</div>}
|
||||
|
||||
<div className="form-section">
|
||||
<div><h2>{requiresFamily ? '4' : requiresParent ? '3' : '2'}. Identificación</h2><p className="section-copy">Para comenzar sólo necesitamos un nombre claro.</p></div>
|
||||
<label className="field"><span>Nombre <em>obligatorio</em></span><input autoFocus value={name} onChange={(event)=>setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} /></label>
|
||||
<details style={{marginTop:12}}><summary style={{cursor:'pointer',fontWeight:700}}>Datos opcionales</summary><div style={{marginTop:14}}>
|
||||
<div className="form-grid">
|
||||
<label className="field"><span>Código DH</span><input value={code} onChange={(event)=>setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Se genera solo si lo dejás vacío" /></label>
|
||||
<label className="field"><span>Nombre habitual</span><input value={commonName} onChange={(event)=>setCommonName(event.target.value)} maxLength={200} /></label>
|
||||
</div>
|
||||
<label className="field"><span>Descripción</span><textarea value={description} onChange={(event)=>setDescription(event.target.value)} rows={2} maxLength={4000} /></label>
|
||||
</div></details>
|
||||
</div>
|
||||
|
||||
<div className="form-section"><div><h2>1. ¿Qué querés crear?</h2><p className="section-copy">Departamento → Área → Yacimiento → Instalación → Subinstalación. Empresa queda fuera del árbol físico.</p></div><label className="field"><span>Tipo</span><select value={kind} onChange={(event)=>changeKind(event.target.value as InventoryStructureKind)}>{KINDS.map((item)=><option key={item.kind} value={item.kind}>{item.label}</option>)}</select></label></div>
|
||||
{requiresParent && <div className="form-section"><div><h2>2. Ubicación</h2><p className="section-copy">Elegí el {parentLabel.toLowerCase()} concreto.</p></div><label className="field"><span>Buscar {parentLabel.toLowerCase()}</span><input value={parentSearch} onChange={(event)=>setParentSearch(event.target.value)} placeholder={`Buscar ${parentLabel.toLowerCase()}…`} /></label><label className="field"><span>{parentLabel} <em>obligatorio</em></span><select value={parentId} onChange={(event)=>{setParentId(event.target.value);setFamilyId('');}} required><option value="">Seleccionar…</option>{parents.map((parent)=><option key={parent.id} value={parent.id}>{parent.name} · {parent.code}</option>)}</select></label></div>}
|
||||
{requiresFamily && <div className="form-section"><div><h2>3. Clasificación técnica</h2><p className="section-copy">Define los datos técnicos y Hallazgos aplicables. En Subinstalaciones sólo aparecen clasificaciones compatibles con la Instalación elegida.</p></div>{kind==='SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación elegida todavía no tiene clasificación técnica.</Alert> : <label className="field"><span>Tipo de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span><select value={familyId} onChange={(event)=>setFamilyId(event.target.value)} required><option value="">Seleccionar…</option>{families.map((family)=><option key={family.id} value={family.id}>{family.name}</option>)}</select></label>}{kind==='SUBINSTALACION' && selectedParent?.inventoryFamily && families.length===0 && <Alert>No hay tipos de Subinstalación compatibles con {selectedParent.inventoryFamily.name}. Configuralos primero en Configuración de Inventarios.</Alert>}{selectedFamily && <div className="context-create-banner"><Icon name="alert" /><div><strong>{selectedFamily.name}</strong><span>{familyFindings ? `${familyFindings.count} Hallazgo${familyFindings.count===1?'':'s'} asociado${familyFindings.count===1?'':'s'}` : 'Cargando Hallazgos asociados…'}</span>{familyFindings && familyFindings.items.length>0 && <ul style={{margin:'8px 0 0',paddingLeft:18}}>{familyFindings.items.slice(0,6).map((item)=><li key={item.id}>{item.title}</li>)}{familyFindings.items.length>6 && <li>+ {familyFindings.items.length-6} más</li>}</ul>}</div></div>}</div>}
|
||||
<div className="form-section"><div><h2>{requiresFamily ? '4' : requiresParent ? '3' : '2'}. Nombre</h2><p className="section-copy">Los datos técnicos se completan después, en la ficha del elemento.</p></div><label className="field"><span>Nombre <em>obligatorio</em></span><input autoFocus value={name} onChange={(event)=>setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} /></label><details style={{marginTop:12}}><summary style={{cursor:'pointer',fontWeight:700}}>Datos opcionales</summary><div style={{marginTop:14}}><div className="form-grid"><label className="field"><span>Código DH</span><input value={code} onChange={(event)=>setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Dejalo vacío para generar automáticamente" /></label><label className="field"><span>Nombre habitual</span><input value={commonName} onChange={(event)=>setCommonName(event.target.value)} maxLength={200} /></label></div><label className="field"><span>Descripción</span><textarea value={description} onChange={(event)=>setDescription(event.target.value)} rows={2} maxLength={4000} /></label></div></details></div>
|
||||
<div className="form-actions"><Link className="button secondary" to="/inventarios">Cancelar</Link><button className="button primary" disabled={saving || !name.trim() || (requiresParent && !parentId) || (requiresFamily && !familyId)}><Icon name="check" />{saving?'Creando…':`Crear ${kindLabel(kind)}`}</button></div>
|
||||
</form>
|
||||
</section>;
|
||||
|
||||
Reference in New Issue
Block a user