F5.1: Configuración de Inventarios con Departamento y acceso a Hallazgos

This commit is contained in:
2026-09-09 07:54:52 -03:00
parent 0f56e57d62
commit 2ce6b16a96
+20 -18
View File
@@ -31,15 +31,16 @@ const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> =
{ value: 'SELECT', label: 'Lista de opciones' },
];
type CanonicalKind = 'EMPRESA' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
type CanonicalKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
type FamilyEditor = InventoryFamily | 'new' | null;
const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string }> = [
{ kind: 'EMPRESA', label: 'Empresa', description: 'Maestro independiente. Se vincula temporalmente a un Área.' },
{ kind: 'AREA', label: 'Área', description: 'Raíz territorial de la estructura física.' },
{ kind: 'DEPARTAMENTO', label: 'Departamento', description: 'Raíz territorial de la estructura física.' },
{ kind: 'AREA', label: 'Área', description: 'Pertenece obligatoriamente a un Departamento.' },
{ kind: 'YACIMIENTO', label: 'Yacimiento', description: 'Pertenece a un Área; su nombre puede repetirse en otra Área.' },
{ kind: 'INSTALACION', label: 'Instalación', description: 'Instancia física dentro de un Yacimiento y con clasificación técnica.' },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', description: 'Instancia física dentro de una Instalación y con clasificación técnica.' },
{ kind: 'INSTALACION', label: 'Instalación', description: 'Instancia dentro de un Yacimiento y con clasificación técnica.' },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', description: 'Instancia dentro de una Instalación y con clasificación técnica.' },
];
function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | null {
@@ -192,32 +193,33 @@ export function AssetTypesPage() {
<div>
<span className="eyebrow">ADMINISTRACIÓN</span>
<h1>Configuración de Inventarios</h1>
<p>Administrá la estructura, los tipos de Instalación/Subinstalación y las columnas que se completan en oficina o desde la APK.</p>
<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>
{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">Empresa no es padre del Área. La Operadora/Concesionaria se vincula al Área con vigencia temporal.</p></div></div>
<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>Área</strong><small>raíz territorial</small></div><i></i>
<div><span>2</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i>
<div><span>3</span><strong>Instalación</strong><small>inventario real</small></div><i></i>
<div><span>4</span><strong>Subinstalación</strong><small>inventario real</small></div>
<div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i></i>
<div><span>2</span><strong>Área</strong><small>dentro del Departamento</small></div><i></i>
<div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i>
<div><span>4</span><strong>Instalación</strong><small>clasificación técnica</small></div><i></i>
<div><span>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div>
</div>
<div className="temporal-notice" style={{ marginTop: 14 }}><Icon name="users" /><p><strong>Empresa:</strong> maestro independiente. Puede cambiar la Operadora de un Área sin mover ni reescribir Yacimientos, Instalaciones o Subinstalaciones.</p></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="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">Precargados desde final_modelov2.xlsx y ampliables por Hidrocarburos.</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" key={family.id} onClick={() => openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.sourceReference?.startsWith('F5:final_modelov2.xlsx') ? 'Precargado desde fuente autorizada' : 'Agregado por Hidrocarburos'} · {family.findingCount ?? 0} Hallazgos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>
<div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Instalación</h2><p className="section-copy">Definen el rubro técnico y los Hallazgos aplicables.</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" 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">Cada tipo queda asociado a un tipo de Instalación.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('SUBINSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div>
<div className="attribute-list" style={{ maxHeight: 560, overflow: 'auto' }}>{subinstallationFamilies.filter((family) => family.isActive !== false).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</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>
<div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Subinstalación</h2><p className="section-copy">Cada tipo pertenece a un tipo de Instalación y tiene sus propios Hallazgos.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('SUBINSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div>
<div className="attribute-list" style={{ maxHeight: 560, overflow: 'auto' }}>{subinstallationFamilies.filter((family) => family.isActive !== false).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>
</div>
@@ -228,10 +230,10 @@ export function AssetTypesPage() {
{!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>
<div className="panel" style={{ marginTop: 18 }}><div className="panel-heading"><div><span className="eyebrow">HALLAZGOS</span><h2>Catálogo separado, relación clara</h2><p className="section-copy">Los Hallazgos se crean y editan en su catálogo, y allí se vinculan al tipo de Instalación/Subinstalación correspondiente. OTROS permanece siempre disponible.</p></div><Link className="button secondary" to="/admin/finding-catalog">Abrir Catálogo de hallazgos <Icon name="chevron" /></Link></div></div>
<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">Abrí una clasificación arriba para ver cuántos Hallazgos tiene y entrar directamente a sus Hallazgos asociados. OTROS permanece disponible en la APK.</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 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' && <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, sin borrar registros históricos.</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'}</button></div></form></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>{familyEditor.findingCount ?? 0} Hallazgos asociados.</strong> Revisalos o modificá la asociación desde el Catálogo.</p></div><Link className="button secondary" to={`/admin/finding-catalog?familyId=${familyEditor.id}`}>Ver Hallazgos asociados <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'}</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 datos históricos.</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">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>}
</section>;
}