786 lines
74 KiB
TypeScript
786 lines
74 KiB
TypeScript
import { SearchableSelect } from '../components/SearchableSelect';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import type { FormEvent } from 'react';
|
|
import { useAuth } from '../auth/AuthContext';
|
|
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
|
import { Icon } from '../components/Icon';
|
|
import { AssetImportReviewsPanel } from '../features/assets/AssetImportReviewsPanel';
|
|
import {
|
|
applyAssetImportPlan,
|
|
applySafeAssetImportPlan,
|
|
cancelAssetImport,
|
|
generateAssetImportPlan,
|
|
getAssetImportBatch,
|
|
getAssetImportPlan,
|
|
listAssetImportBatches,
|
|
listAssetImportReviews,
|
|
listAssetImportOrganizations,
|
|
listAssetImportPlanItems,
|
|
listAssetImportRows,
|
|
listAssets,
|
|
reconcileAssetImport,
|
|
resolveAssetImportPlanItem,
|
|
rollbackAssetImportPlan,
|
|
uploadAssetImport,
|
|
} from '../lib/api';
|
|
import type {
|
|
AssetImportBatch,
|
|
AssetImportBatchDetail,
|
|
AssetImportOrganizationOption,
|
|
AssetImportPlan,
|
|
AssetImportPlanAction,
|
|
AssetImportPlanEntityKind,
|
|
AssetImportPlanItem,
|
|
AssetImportProfileCode,
|
|
AssetImportRow,
|
|
AssetImportRowStatus,
|
|
AssetImportReviewItem,
|
|
AssetListItem,
|
|
} from '../lib/api';
|
|
|
|
const ISSUE_LABELS: Record<string, string> = {
|
|
MISSING_AREA_OR_YACIMIENTO: 'Falta Área/Yacimiento',
|
|
MISSING_EQUIPMENT_DESCRIPTION: 'Falta descripción del equipo',
|
|
MISSING_INVENTORY_ID: 'Sin ID de inventario',
|
|
INVALID_QUANTITY: 'Cantidad inválida',
|
|
GROUPED_QUANTITY: 'Registro agrupado: requiere decisión humana',
|
|
SOURCE_STATUS_REQUIRES_MAPPING: 'Estado de fuente ambiguo',
|
|
INVENTORY_ID_MULTIPLE_LOCATIONS: 'ID repetido en ubicaciones diferentes',
|
|
DUPLICATE_INVENTORY_ID_IN_BATCH: 'ID repetido en el archivo',
|
|
MISSING_FIELD: 'Falta Yacimiento',
|
|
MISSING_AREA: 'Falta Área',
|
|
MISSING_OPERATOR: 'Falta Operadora',
|
|
PROFILE_NOT_RECOGNIZED: 'Formato no reconocido',
|
|
MATCH_MULTIPLE_ASSETS: 'Varias coincidencias exactas en los inventarios',
|
|
MATCH_MULTIPLE_ORGANIZATIONS: 'Varias organizaciones coinciden con la operadora',
|
|
SOURCE_ROW_WARNING: 'La fila de origen tiene una advertencia',
|
|
SOURCE_ROW_CONFLICT: 'La fila de origen tiene un conflicto',
|
|
PLAN_MULTIPLE_ORGANIZATIONS: 'Varias organizaciones posibles',
|
|
PLAN_MULTIPLE_AREAS: 'Varias áreas posibles',
|
|
PLAN_MULTIPLE_DEPARTMENTS: 'Varios Departamentos coinciden',
|
|
PLAN_DEPARTMENT_REVIEW_REQUIRED: 'El Departamento requiere revisión',
|
|
PLAN_LEGAL_RIGHT_TYPE_MISSING: 'Falta tipo de derecho/concesión',
|
|
PLAN_LEGAL_RIGHT_TYPE_UNRECOGNIZED: 'Tipo de derecho no reconocido',
|
|
PLAN_MULTIPLE_LEGAL_RIGHTS: 'Varios derechos vigentes coinciden',
|
|
PLAN_LEGAL_RIGHT_REVIEW_REQUIRED: 'El derecho/concesión requiere revisión',
|
|
PLAN_MULTIPLE_FIELDS: 'Varios yacimientos posibles',
|
|
PLAN_MULTIPLE_CONTAINERS: 'Varias instalaciones posibles',
|
|
PLAN_MULTIPLE_LOCAL_STRUCTURES: 'Varias estructuras locales posibles',
|
|
PLAN_MULTIPLE_NAMESPACE_MATCHES: 'El ID externo coincide con varios registros',
|
|
PLAN_GLOBAL_MATCH_REQUIRES_NAMESPACE: 'Coincidencia previa sin namespace confirmado',
|
|
PLAN_AREA_REVIEW_REQUIRED: 'El Área requiere revisión',
|
|
PLAN_ORGANIZATION_REVIEW_REQUIRED: 'La Operadora requiere revisión',
|
|
PLAN_FIELD_OPERATOR_AMBIGUOUS: 'La operadora del Yacimiento es ambigua',
|
|
PLAN_FIELD_OPERATOR_MISSING: 'No hay una operadora identificable para el Yacimiento',
|
|
PLAN_FIELD_OPERATOR_CONTEXT_MISMATCH: 'El Yacimiento existente tiene otra asignación operativa; corregir el inventario o la fuente',
|
|
PLAN_MATCH_OPERATIONAL_CONTEXT_MISMATCH: 'El registro identificado existe pero pertenece a otra Área/Operadora',
|
|
PLAN_TERRITORY_AMBIGUOUS: 'Área/Yacimiento ambiguo para este inventario',
|
|
PLAN_TERRITORY_CONTEXT_REQUIRED: 'Primero hay que elegir Área/Yacimiento',
|
|
PLAN_TERRITORY_OVERRIDE_STALE: 'La elección territorial guardada ya no es válida',
|
|
PLAN_CONTEXT_DECISION_REQUIRED: 'Depende de elegir el contexto territorial',
|
|
PLAN_TERRITORY_NOT_FOUND: 'Área/Yacimiento no existe en los inventarios para esta operadora',
|
|
PLAN_CONTAINER_REVIEW_REQUIRED: 'La instalación padre requiere revisión',
|
|
PLAN_CONTAINER_NOT_IDENTIFIED: 'No se identificó una instalación física segura',
|
|
PLAN_LOCAL_STRUCTURE_NOT_IDENTIFIED: 'No se pudo formar una estructura local desde la fuente',
|
|
PLAN_PARENT_NOT_RESOLVED: 'No se pudo resolver el padre físico',
|
|
PLAN_PROFILE_UNSUPPORTED: 'Perfil todavía no soportado para aplicación',
|
|
PLAN_SOURCE_GROUP_REQUIRES_DECISION: 'La fuente sólo informa una categoría/sector; confirmar contenedor',
|
|
PLAN_INVENTORY_ID_TOO_LONG: 'ID de inventario supera el máximo permitido',
|
|
};
|
|
|
|
const ENTITY_LABELS: Record<AssetImportPlanEntityKind, string> = {
|
|
DEPARTMENT: 'Departamentos',
|
|
ORGANIZATION: 'Organizaciones',
|
|
AREA: 'Áreas',
|
|
AREA_DEPARTMENT_RELATION: 'Relaciones Área ↔ Departamento',
|
|
FIELD: 'Yacimientos',
|
|
OPERATOR_RELATION: 'Relaciones Área ↔ Operadora',
|
|
LEGAL_RIGHT: 'Derechos / concesiones',
|
|
LEGAL_RIGHT_ORGANIZATION: 'Derecho ↔ Organización',
|
|
INSTALLATION: 'Instalaciones',
|
|
LOCAL_STRUCTURE: 'Estructura local según fuente',
|
|
TECHNICAL_ASSET: 'Elementos técnicos',
|
|
};
|
|
|
|
const PLAN_GROUPS: Array<{ label: string; kinds: AssetImportPlanEntityKind[] }> = [
|
|
{ label: 'Territorio', kinds: ['DEPARTMENT','AREA','AREA_DEPARTMENT_RELATION','FIELD'] },
|
|
{ label: 'Organizaciones', kinds: ['ORGANIZATION','OPERATOR_RELATION'] },
|
|
{ label: 'Marco jurídico', kinds: ['LEGAL_RIGHT','LEGAL_RIGHT_ORGANIZATION'] },
|
|
{ label: 'Inventario técnico', kinds: ['LOCAL_STRUCTURE','INSTALLATION','TECHNICAL_ASSET'] },
|
|
];
|
|
|
|
|
|
const PLAN_ACTION_LABELS: Record<AssetImportPlanAction, string> = {
|
|
CREATE: 'Crear nuevo', MATCH: 'Usar existente', REVIEW: 'Requiere revisión', IGNORE: 'Ignorar',
|
|
};
|
|
|
|
const MATCH_CRITERIA: Record<AssetImportPlanEntityKind, string> = {
|
|
DEPARTMENT: 'Mismo Departamento normalizado dentro de Mendoza.',
|
|
ORGANIZATION: 'Mismo nombre o razón social normalizada.',
|
|
AREA: 'Mismo nombre de Área en los inventarios.',
|
|
AREA_DEPARTMENT_RELATION: 'La relación Área ↔ Departamento ya está vigente.',
|
|
FIELD: 'Mismo Yacimiento dentro de la misma Área y contexto operativo.',
|
|
OPERATOR_RELATION: 'La relación Área ↔ Operadora ya está vigente.',
|
|
LEGAL_RIGHT: 'Mismo derecho/concesión vigente para el Área.',
|
|
LEGAL_RIGHT_ORGANIZATION: 'La relación Derecho ↔ Organización ya está vigente.',
|
|
INSTALLATION: 'Mismo tipo y nombre bajo el mismo padre físico.',
|
|
LOCAL_STRUCTURE: 'Misma nomenclatura local bajo el mismo Área/Yacimiento y la misma operadora.',
|
|
TECHNICAL_ASSET: 'ID externo exacto dentro del namespace seleccionado y mismo contexto operativo.',
|
|
};
|
|
|
|
type ReviewGuidance = { title: string; detail: string; decision: string; dependency?: boolean };
|
|
const REVIEW_GUIDANCE: Record<string, ReviewGuidance> = {
|
|
MISSING_AREA_OR_YACIMIENTO:{title:'Falta ubicación territorial',detail:'La fila no informa Área o Yacimiento suficiente para ubicar el registro.',decision:'Completá la ubicación en la fuente o vinculala con el contexto correcto.'},
|
|
MISSING_EQUIPMENT_DESCRIPTION:{title:'Falta identificar el equipo',detail:'No hay una descripción suficiente para saber qué registro representa la fila.',decision:'Completá la descripción antes de crear un registro.'},
|
|
MISSING_INVENTORY_ID:{title:'Falta ID de inventario',detail:'La fuente no aporta un identificador estable para este equipo.',decision:'Confirmá si debe crearse sin ID externo o corregí la fuente.'},
|
|
INVALID_QUANTITY:{title:'Cantidad inválida',detail:'La cantidad informada no puede interpretarse con seguridad.',decision:'Corregí o confirmá la cantidad antes de importar.'},
|
|
GROUPED_QUANTITY:{title:'La fila agrupa varias unidades',detail:'La fuente representa más de una unidad en una sola fila y el sistema no debe separarlas automáticamente.',decision:'Confirmá si se conserva como registro agrupado o si la fuente debe dividirse en registros individuales.'},
|
|
SOURCE_STATUS_REQUIRES_MAPPING:{title:'Estado de fuente ambiguo',detail:'El texto de estado no permite decidir automáticamente entre estado operativo y condición.',decision:'Revisá qué significa el estado antes de importar.'},
|
|
INVENTORY_ID_MULTIPLE_LOCATIONS:{title:'El mismo ID aparece en ubicaciones distintas',detail:'Un identificador de inventario se repite con contextos territoriales diferentes.',decision:'Verificá si es el mismo registro trasladado o un error de identificación.'},
|
|
DUPLICATE_INVENTORY_ID_IN_BATCH:{title:'ID repetido dentro del archivo',detail:'El mismo ID de inventario aparece más de una vez en este lote.',decision:'Confirmá si las filas describen el mismo registro o corregí el identificador.'},
|
|
MISSING_FIELD:{title:'Falta Yacimiento',detail:'No se pudo identificar el Yacimiento de la fila.',decision:'Completá o confirmá el Yacimiento correcto.'},
|
|
MISSING_AREA:{title:'Falta Área',detail:'No se pudo identificar el Área operativa de la fila.',decision:'Completá o confirmá el Área correcta.'},
|
|
MISSING_OPERATOR:{title:'Falta Operadora',detail:'La fuente no permite determinar una organización operadora.',decision:'Confirmá la operadora o mantené explícitamente el registro sin operadora cuando corresponda.'},
|
|
PROFILE_NOT_RECOGNIZED:{title:'Formato no reconocido',detail:'El archivo no coincide con un perfil de importación conocido.',decision:'Usá un perfil soportado o ajustá el archivo.'},
|
|
MATCH_MULTIPLE_ASSETS:{title:'Hay varios registros posibles',detail:'Más de un registro cumple los criterios exactos disponibles.',decision:'Elegí manualmente cuál registro del inventario corresponde.'},
|
|
MATCH_MULTIPLE_ORGANIZATIONS:{title:'Hay varias organizaciones posibles',detail:'La operadora informada coincide con más de una organización.',decision:'Seleccioná la organización legal correcta.'},
|
|
SOURCE_ROW_WARNING:{title:'Advertencia en la fila de origen',detail:'La fila contiene información válida pero con una condición que requiere control humano.',decision:'Revisá las observaciones de origen y confirmá la decisión.'},
|
|
SOURCE_ROW_CONFLICT:{title:'Conflicto en la fila de origen',detail:'La fila contiene datos que se contradicen o no pueden resolverse automáticamente.',decision:'Corregí la fuente o resolvé manualmente identidad y contexto.'},
|
|
PLAN_MULTIPLE_ORGANIZATIONS:{title:'Varias organizaciones coinciden',detail:'No existe una única organización segura para reutilizar.',decision:'Elegí la organización correcta o corregí el inventario.'},
|
|
PLAN_MULTIPLE_AREAS:{title:'Varias Áreas coinciden',detail:'El nombre normalizado no identifica una sola Área.',decision:'Elegí el Área correcta o corregí la duplicidad en el inventario.'},
|
|
PLAN_MULTIPLE_DEPARTMENTS:{title:'Varios Departamentos coinciden',detail:'El Departamento no queda identificado de manera única.',decision:'Elegí o corregí el Departamento correspondiente.'},
|
|
PLAN_DEPARTMENT_REVIEW_REQUIRED:{title:'Depende de un Departamento pendiente',detail:'Esta relación no puede resolverse hasta decidir el Departamento relacionado.',decision:'Resolvé primero el Departamento y luego regenerá el plan.',dependency:true},
|
|
PLAN_LEGAL_RIGHT_TYPE_MISSING:{title:'Falta tipo de derecho',detail:'La fuente no informa el tipo de derecho/concesión necesario.',decision:'Completá o confirmá el tipo jurídico.'},
|
|
PLAN_LEGAL_RIGHT_TYPE_UNRECOGNIZED:{title:'Tipo de derecho no reconocido',detail:'El valor de fuente no puede mapearse con seguridad al catálogo jurídico.',decision:'Elegí el tipo correcto o corregí la fuente.'},
|
|
PLAN_MULTIPLE_LEGAL_RIGHTS:{title:'Varios derechos vigentes coinciden',detail:'Hay más de un derecho/concesión posible para el mismo contexto.',decision:'Seleccioná el derecho correcto.'},
|
|
PLAN_LEGAL_RIGHT_REVIEW_REQUIRED:{title:'Depende de un derecho pendiente',detail:'Este elemento depende de un derecho/concesión todavía no resuelto.',decision:'Resolvé primero el derecho y regenerá el plan.',dependency:true},
|
|
PLAN_MULTIPLE_FIELDS:{title:'Varios Yacimientos posibles',detail:'El nombre y contexto no identifican un único Yacimiento.',decision:'Elegí el Yacimiento correcto o corregí duplicidades.'},
|
|
PLAN_MULTIPLE_CONTAINERS:{title:'Varias instalaciones posibles',detail:'Más de una instalación física coincide bajo el mismo contexto.',decision:'Elegí la instalación correcta.'},
|
|
PLAN_MULTIPLE_LOCAL_STRUCTURES:{title:'Varias estructuras locales posibles',detail:'La misma nomenclatura local aparece más de una vez dentro del mismo contexto.',decision:'Revisá si son duplicados o estructuras distintas antes de continuar.'},
|
|
PLAN_MULTIPLE_NAMESPACE_MATCHES:{title:'El ID externo apunta a varios registros',detail:'Dentro del namespace seleccionado el mismo ID externo está asociado a más de un registro.',decision:'Corregí la duplicidad del identificador.'},
|
|
PLAN_GLOBAL_MATCH_REQUIRES_NAMESPACE:{title:'Coincidencia previa sin namespace seguro',detail:'Existe una coincidencia histórica, pero no está respaldada por el namespace externo seleccionado.',decision:'Verificá manualmente el registro o completá el identificador externo.'},
|
|
PLAN_AREA_REVIEW_REQUIRED:{title:'Depende de un Área pendiente',detail:'Este elemento no puede resolverse hasta definir el Área relacionada.',decision:'Resolvé primero el Área y regenerá el plan.',dependency:true},
|
|
PLAN_ORGANIZATION_REVIEW_REQUIRED:{title:'Depende de una organización pendiente',detail:'Este elemento necesita una organización que todavía no está resuelta.',decision:'Resolvé primero la organización y regenerá el plan.',dependency:true},
|
|
PLAN_FIELD_OPERATOR_AMBIGUOUS:{title:'Operadora del Yacimiento ambigua',detail:'La fuente permite más de una interpretación para la operadora del Yacimiento.',decision:'Confirmá la organización operadora correcta.'},
|
|
PLAN_FIELD_OPERATOR_MISSING:{title:'Yacimiento sin operadora identificable',detail:'No se pudo obtener una operadora segura para el Yacimiento.',decision:'Confirmá si realmente está sin operadora o completá la organización.'},
|
|
PLAN_FIELD_OPERATOR_CONTEXT_MISMATCH:{title:'Yacimiento con otro contexto operativo',detail:'El Yacimiento existente está asociado a una operadora diferente de la fuente.',decision:'Corregí el inventario o la fuente; no fuerces una coincidencia incorrecta.'},
|
|
PLAN_MATCH_OPERATIONAL_CONTEXT_MISMATCH:{title:'El registro existe en otro contexto',detail:'El identificador coincide, pero Área u Operadora no coinciden con la fuente.',decision:'Verificá traslado, error de fuente o contexto del inventario.'},
|
|
PLAN_TERRITORY_AMBIGUOUS:{title:'Elegir Área/Yacimiento',detail:'El mismo nombre existe en más de un contexto territorial válido. El sistema no va a adivinar cuál corresponde.',decision:'Elegí una de las alternativas detectadas. La decisión se guardará para este lote y luego se regenera el plan.'},
|
|
PLAN_TERRITORY_CONTEXT_REQUIRED:{title:'Depende del contexto territorial',detail:'No se analiza todavía la estructura local porque primero hay que decidir a qué Área/Yacimiento pertenece la fila.',decision:'Resolvé la tarjeta “Elegir Área/Yacimiento” y regenerá el plan.',dependency:true},
|
|
PLAN_CONTEXT_DECISION_REQUIRED:{title:'Esperando Área/Yacimiento',detail:'La instalación y el padre no son conflictos separados: se recalcularán cuando se elija el contexto territorial.',decision:'No resuelvas este registro individualmente; resolvé primero el contexto y regenerá.',dependency:true},
|
|
PLAN_TERRITORY_OVERRIDE_STALE:{title:'La elección territorial quedó obsoleta',detail:'El inventario cambió y el contexto elegido anteriormente ya no está entre las alternativas válidas.',decision:'Elegí nuevamente el Área/Yacimiento correcto.'},
|
|
PLAN_TERRITORY_NOT_FOUND:{title:'Área/Yacimiento no encontrado para esta operadora',detail:'La ubicación de la fila no existe dentro de las Áreas operadas por la organización seleccionada.',decision:'Importá/configurá primero el territorio correcto o corregí la fuente.'},
|
|
PLAN_CONTAINER_REVIEW_REQUIRED:{title:'Depende de una instalación pendiente',detail:'El registro necesita una instalación padre que todavía requiere decisión.',decision:'Resolvé primero la instalación y regenerá el plan.',dependency:true},
|
|
PLAN_CONTAINER_NOT_IDENTIFIED:{title:'No se identificó instalación física',detail:'La fila no aporta una instalación concreta y el equipo necesita un padre físico.',decision:'Identificá la instalación real o corregí la estructura de la fuente.'},
|
|
PLAN_LOCAL_STRUCTURE_NOT_IDENTIFIED:{title:'Falta estructura local utilizable',detail:'La fuente no aporta un nivel local suficiente para ubicar el registro debajo del Área/Yacimiento.',decision:'Revisá instalación, subinstalación o ubicación y confirmá el nivel estructural correcto.'},
|
|
PLAN_PARENT_NOT_RESOLVED:{title:'Padre físico sin resolver',detail:'El sistema no puede incorporar el registro sin un padre físico seguro.',decision:'Resolvé Área/Yacimiento/instalación padre y luego regenerá el plan.',dependency:true},
|
|
PLAN_PROFILE_UNSUPPORTED:{title:'Perfil no soportado para incorporación',detail:'El análisis puede leerse, pero este perfil todavía no tiene una incorporación segura al inventario.',decision:'No apliques el lote hasta contar con soporte específico.'},
|
|
PLAN_SOURCE_GROUP_REQUIRES_DECISION:{title:'Nomenclatura local de la fuente',detail:'Este plan utiliza un criterio anterior para clasificar la estructura local.',decision:'Regenerá el plan con la versión actual antes de resolver este caso.'},
|
|
PLAN_INVENTORY_ID_TOO_LONG:{title:'ID de inventario demasiado largo',detail:'El identificador supera el máximo permitido para persistirlo de forma segura.',decision:'Corregí o normalizá el ID en la fuente.'},
|
|
};
|
|
function reviewGuidance(code:string):ReviewGuidance{return REVIEW_GUIDANCE[code]??{title:ISSUE_LABELS[code]??code,detail:'El sistema no puede tomar esta decisión automáticamente.',decision:'Revisá el registro y resolvelo manualmente antes de aplicar.'};}
|
|
function isDependencyReview(item:AssetImportPlanItem){return item.reviewCodes.length>0&&item.reviewCodes.every((code)=>reviewGuidance(code).dependency===true);}
|
|
function provisionalDisplayName(name:string){return name.replace(/^\[A VALIDAR\]\s*/i,'').trim()||name;}
|
|
function isProvisionalName(name:string){return /^\[A VALIDAR\]/i.test(name.trim());}
|
|
function matchedObjectId(item:AssetImportPlanItem){if(item.matchedAssetId)return item.matchedAssetId;for(const key of ['matchedDepartmentId','matchedRelationId','matchedLegalRightId']){const current=item.payload[key];if(typeof current==='string'&¤t)return current;}return null;}
|
|
function reviewFacts(item:AssetImportPlanItem){const p=item.payload;const entries:Array<[string,unknown]>=[['Área / Yacimiento',p.areaOrField],['Instalación',p.installation??p.sourceInstallation],['Sector / subinstalación',p.subInstallation??p.sourceSubInstallation],['Equipo',p.equipment],['Familia normalizada',p.normalizedFamily],['Subtipo',p.normalizedSubtype],['ID inventario',p.inventoryId],['Cantidad',p.quantity],['Estado fuente',p.sourceStatus]];return entries.filter(([,v])=>v!=null&&v!=='').slice(0,7).map(([label,v])=>[label,String(v)] as const);}
|
|
|
|
type InventoryContextCandidate = { id:string; code:string; name:string; typeCode:string; parentName?:string|null };
|
|
function inventoryContextCandidates(item:AssetImportPlanItem):InventoryContextCandidate[]{
|
|
if(item.payload.inventoryContextDecision!==true||!Array.isArray(item.payload.candidateOptions))return [];
|
|
return item.payload.candidateOptions.flatMap((value)=>{
|
|
const current=record(value);if(!current)return [];
|
|
const id=String(current.id??'');const code=String(current.code??'');const name=String(current.name??'');const typeCode=String(current.typeCode??'');
|
|
if(!id||!name||!typeCode)return [];
|
|
return [{id,code,name,typeCode,parentName:current.parentName?String(current.parentName):null}];
|
|
});
|
|
}
|
|
|
|
function profileLabel(code: AssetImportProfileCode) {
|
|
if (code === 'MENDOZA_INVENTORY_V1') return 'Inventario de instalaciones · Mendoza';
|
|
if (code === 'MENDOZA_YACIMIENTOS_V1') return 'Tabla Área / Yacimiento · Mendoza';
|
|
return 'Formato no reconocido';
|
|
}
|
|
|
|
function record(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
}
|
|
|
|
function executionStatus(batch: AssetImportBatch) {
|
|
return record(batch.analysis.importExecution)?.status;
|
|
}
|
|
|
|
function batchStatus(batch: AssetImportBatch) {
|
|
const execution = executionStatus(batch);
|
|
if (execution === 'APPLIED') return { label: 'Importado', className: 'active' };
|
|
if (execution === 'PARTIAL') return { label: 'Importado parcialmente', className: 'pending' };
|
|
if (execution === 'ROLLED_BACK') return { label: 'Revertido', className: 'pending' };
|
|
if (batch.status === 'CANCELLED') return { label: 'Cancelado', className: 'inactive' };
|
|
if (batch.status === 'REVIEW_REQUIRED') return { label: 'Requiere revisión', className: 'observed' };
|
|
if (batch.status === 'FAILED') return { label: 'Fallido', className: 'inactive' };
|
|
return { label: 'Analizado', className: 'active' };
|
|
}
|
|
|
|
function rowStatus(status: AssetImportRowStatus) {
|
|
if (status === 'READY') return { label: 'Listo', className: 'active' };
|
|
if (status === 'WARNING') return { label: 'Advertencia', className: 'pending' };
|
|
if (status === 'CONFLICT') return { label: 'Conflicto', className: 'observed' };
|
|
return { label: 'Ignorado', className: 'inactive' };
|
|
}
|
|
|
|
function planState(plan: AssetImportPlan) {
|
|
if (plan.status === 'READY') return { label: 'Listo para confirmar', className: 'active' };
|
|
if (plan.status === 'REVIEW_REQUIRED' && summaryNumber(plan, 'appliedCreateItems') > 0) return { label: 'Importado parcialmente', className: 'pending' };
|
|
if (plan.status === 'REVIEW_REQUIRED') return { label: 'Decisiones pendientes', className: 'observed' };
|
|
if (plan.status === 'APPLIED') return { label: 'Aplicado', className: 'active' };
|
|
if (plan.status === 'ROLLED_BACK') return { label: 'Revertido', className: 'pending' };
|
|
if (plan.status === 'FAILED') return { label: 'Fallido', className: 'inactive' };
|
|
return { label: plan.status, className: 'inactive' };
|
|
}
|
|
|
|
function value(source: Record<string, unknown>, key: string) {
|
|
const current = source[key];
|
|
return current == null || current === '' ? '—' : String(current);
|
|
}
|
|
|
|
function formatBytes(bytes: number) {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
}
|
|
|
|
function summaryNumber(plan: AssetImportPlan, key: string) {
|
|
return Number(plan.summary[key] ?? 0);
|
|
}
|
|
|
|
function directReviewCount(plan: AssetImportPlan) {
|
|
const value = plan.summary.directReviewItems;
|
|
return value == null ? plan.reviewItems.filter((item) => !isDependencyReview(item)).length : Number(value);
|
|
}
|
|
|
|
function dependencyReviewCount(plan: AssetImportPlan) {
|
|
const value = plan.summary.dependencyReviewItems;
|
|
return value == null ? plan.reviewItems.filter((item) => isDependencyReview(item)).length : Number(value);
|
|
}
|
|
|
|
function sourceRowsLabel(rows: number[]) {
|
|
if (!rows.length) return 'Contexto del inventario';
|
|
if (rows.length <= 4) return `Fila${rows.length === 1 ? '' : 's'} ${rows.join(', ')}`;
|
|
return `${rows.length.toLocaleString('es-AR')} filas · ${rows.slice(0, 3).join(', ')}…`;
|
|
}
|
|
|
|
export function AssetImportsPage() {
|
|
const { hasPermission } = useAuth();
|
|
const canManage = hasPermission('asset_imports.manage');
|
|
const canApply = hasPermission('asset_imports.apply');
|
|
const [batches, setBatches] = useState<AssetImportBatch[]>([]);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [detail, setDetail] = useState<AssetImportBatchDetail | null>(null);
|
|
const [plan, setPlan] = useState<AssetImportPlan | null>(null);
|
|
const [organizations, setOrganizations] = useState<AssetImportOrganizationOption[]>([]);
|
|
const [operatorAssetId, setOperatorAssetId] = useState('');
|
|
const [externalNamespace, setExternalNamespace] = useState('');
|
|
const [rows, setRows] = useState<AssetImportRow[]>([]);
|
|
const [rowMeta, setRowMeta] = useState({ page: 1, pageSize: 50, total: 0, totalPages: 0 });
|
|
const [rowStatusFilter, setRowStatusFilter] = useState<AssetImportRowStatus | ''>('');
|
|
const [rowSearch, setRowSearch] = useState('');
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [sourceLabel, setSourceLabel] = useState('');
|
|
const [notes, setNotes] = useState('');
|
|
const [profileCode, setProfileCode] = useState<'' | Exclude<AssetImportProfileCode, 'UNKNOWN'>>('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [success, setSuccess] = useState('');
|
|
const [matchItem, setMatchItem] = useState<AssetImportPlanItem | null>(null);
|
|
const [matchSearch, setMatchSearch] = useState('');
|
|
const [matchResults, setMatchResults] = useState<AssetListItem[]>([]);
|
|
const [matchLoading, setMatchLoading] = useState(false);
|
|
const [planDetailKind, setPlanDetailKind] = useState<AssetImportPlanEntityKind | null>(null);
|
|
const [planDetailAction, setPlanDetailAction] = useState<AssetImportPlanAction | null>(null);
|
|
const [planDetailItems, setPlanDetailItems] = useState<AssetImportPlanItem[]>([]);
|
|
const [planDetailMeta, setPlanDetailMeta] = useState({ page: 1, pageSize: 50, total: 0, totalPages: 0 });
|
|
const [planDetailLoading, setPlanDetailLoading] = useState(false);
|
|
const [section, setSection] = useState<'BATCHES' | 'REVIEWS'>('BATCHES');
|
|
const [reviews, setReviews] = useState<AssetImportReviewItem[]>([]);
|
|
const [reviewKind, setReviewKind] = useState<'ALL' | 'DIRECT' | 'DEPENDENCY'>('ALL');
|
|
const [reviewMeta, setReviewMeta] = useState({ page: 1, pageSize: 30, total: 0, totalPages: 0 });
|
|
const [reviewsLoading, setReviewsLoading] = useState(false);
|
|
const [scrollToReviews, setScrollToReviews] = useState(false);
|
|
|
|
const loadPlanDetail = async (kind: AssetImportPlanEntityKind | null, page = 1, action: AssetImportPlanAction | null = null) => {
|
|
if (!detail) return;
|
|
setPlanDetailKind(kind); setPlanDetailAction(action); setPlanDetailLoading(true);
|
|
try {
|
|
const response = await listAssetImportPlanItems(detail.id, { entityKind: kind ?? undefined, action: action ?? undefined, page, pageSize: 50 });
|
|
setPlanDetailItems(response.data); setPlanDetailMeta(response.meta);
|
|
} catch (current) { setError(errorMessage(current)); }
|
|
finally { setPlanDetailLoading(false); }
|
|
};
|
|
|
|
const loadReviews = async (page = 1, kind = reviewKind) => {
|
|
setReviewsLoading(true);
|
|
try {
|
|
const response = await listAssetImportReviews({ page, pageSize: 30, kind });
|
|
setReviews(response.data); setReviewMeta(response.meta);
|
|
} catch (current) { setError(errorMessage(current)); }
|
|
finally { setReviewsLoading(false); }
|
|
};
|
|
|
|
const loadBatches = async (preferId?: string) => {
|
|
const response = await listAssetImportBatches({ pageSize: 100 });
|
|
setBatches(response.data);
|
|
const next = preferId ?? selectedId ?? response.data[0]?.id ?? null;
|
|
setSelectedId(next);
|
|
};
|
|
|
|
const loadOrganizations = async () => {
|
|
const response = await listAssetImportOrganizations();
|
|
setOrganizations(response.data);
|
|
};
|
|
|
|
const loadDetail = async (id: string, page = 1) => {
|
|
setDetailLoading(true); setError('');
|
|
try {
|
|
const [batch, rowResponse, currentPlan] = await Promise.all([
|
|
getAssetImportBatch(id),
|
|
listAssetImportRows(id, { page, pageSize: 50, status: rowStatusFilter, search: rowSearch.trim() || undefined }),
|
|
getAssetImportPlan(id),
|
|
]);
|
|
setDetail(batch); setRows(rowResponse.data); setRowMeta(rowResponse.meta); setPlan(currentPlan); setPlanDetailKind(null); setPlanDetailAction(null); setPlanDetailItems([]);
|
|
if (currentPlan?.operatorAssetId) setOperatorAssetId(currentPlan.operatorAssetId);
|
|
if (currentPlan?.externalIdNamespace) setExternalNamespace(currentPlan.externalIdNamespace);
|
|
if (batch.profileCode === 'MENDOZA_INVENTORY_V1') await loadOrganizations();
|
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
|
finally { setDetailLoading(false); }
|
|
};
|
|
|
|
useEffect(() => {
|
|
Promise.all([loadBatches(), loadReviews(1, 'ALL')]).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setOperatorAssetId(''); setExternalNamespace(''); setMatchItem(null); setMatchResults([]); setPlanDetailAction(null);
|
|
if (selectedId) void loadDetail(selectedId, 1);
|
|
else { setDetail(null); setPlan(null); setRows([]); }
|
|
}, [selectedId, rowStatusFilter]);
|
|
|
|
useEffect(() => {
|
|
if (!scrollToReviews || section !== 'BATCHES' || !detail || detail.id !== selectedId || plan?.status !== 'REVIEW_REQUIRED') return;
|
|
const target = document.getElementById('import-review-panel');
|
|
if (!target) return;
|
|
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
setScrollToReviews(false);
|
|
}, [scrollToReviews, section, detail, selectedId, plan]);
|
|
|
|
const analyze = async (event: FormEvent) => {
|
|
event.preventDefault();
|
|
if (!file) { setError('Seleccioná un archivo XLSX o CSV'); return; }
|
|
setSaving(true); setError(''); setSuccess('');
|
|
try {
|
|
const created = await uploadAssetImport({ file, sourceLabel, notes, profileCode: profileCode || undefined });
|
|
setFile(null); setSourceLabel(''); setNotes(''); setProfileCode('');
|
|
await loadBatches(created.id); setSelectedId(created.id);
|
|
setSuccess(`Archivo analizado: ${created.totalRows.toLocaleString('es-AR')} filas. Los inventarios no fueron modificados.`);
|
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const reconcile = async () => {
|
|
if (!detail || detail.status === 'CANCELLED' || plan?.status === 'APPLIED' || executionStatus(detail) === 'PARTIAL') return;
|
|
setSaving(true); setError(''); setSuccess('');
|
|
try {
|
|
await reconcileAssetImport(detail.id);
|
|
await loadBatches(detail.id); await loadDetail(detail.id, rowMeta.page); await loadReviews(1, reviewKind);
|
|
setSuccess('Conciliación completada. Las advertencias quedan para revisión y no se autoaprueban.');
|
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const generatePlan = async () => {
|
|
if (!detail || !detail.analysis.reconciliation) return;
|
|
if (detail.profileCode === 'MENDOZA_INVENTORY_V1' && !operatorAssetId) { setError('Seleccioná la organización operadora antes de generar el plan.'); return; }
|
|
setSaving(true); setError(''); setSuccess('');
|
|
try {
|
|
const created = await generateAssetImportPlan(detail.id, {
|
|
operatorAssetId: detail.profileCode === 'MENDOZA_INVENTORY_V1' ? operatorAssetId : undefined,
|
|
externalIdNamespace: detail.profileCode === 'MENDOZA_INVENTORY_V1' && externalNamespace.trim() ? externalNamespace.trim() : undefined,
|
|
});
|
|
setPlan(created); await loadBatches(detail.id); await loadDetail(detail.id, rowMeta.page); await loadReviews(1, reviewKind);
|
|
setSuccess(created.status === 'READY'
|
|
? 'Plan generado por entidades únicas. Está listo para confirmación.'
|
|
: `Revisión obligatoria: quedan ${directReviewCount(created).toLocaleString('es-AR')} decisión/es humana/s. ${dependencyReviewCount(created).toLocaleString('es-AR')} dependencia/s se recalcularán automáticamente.`);
|
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const resolveReview = async (item: AssetImportPlanItem, action: 'CREATE' | 'IGNORE', reasonLabel: string) => {
|
|
if (!detail) return;
|
|
const reason = window.prompt(`Motivo para ${reasonLabel.toLowerCase()} “${item.displayName}”:`, reasonLabel);
|
|
if (!reason || reason.trim().length < 5) return;
|
|
setSaving(true); setError(''); setSuccess('');
|
|
try {
|
|
const updated = await resolveAssetImportPlanItem(detail.id, item.id, { action, reason: reason.trim() });
|
|
setPlan(updated); await loadReviews(1, reviewKind); setSuccess(`Revisión resuelta: ${item.displayName}.`);
|
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const searchMatch = async (item: AssetImportPlanItem, query?: string) => {
|
|
const search = (query ?? item.displayName).replace(/ · .+$/, '').trim();
|
|
setMatchItem(item); setMatchSearch(search); setMatchResults([]); setMatchLoading(true); setError('');
|
|
try {
|
|
const response = await listAssets({ pageSize: 20, search: search || undefined });
|
|
setMatchResults(response.data.filter((asset) => !item.assetTypeCode || asset.type.code === item.assetTypeCode));
|
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
|
finally { setMatchLoading(false); }
|
|
};
|
|
|
|
const chooseMatch = async (asset: AssetListItem) => {
|
|
if (!detail || !matchItem) return;
|
|
if (!window.confirm(`¿Vincular “${matchItem.displayName}” con ${asset.code} · ${asset.name}?`)) return;
|
|
const reason = window.prompt('Motivo de la vinculación manual:', 'Vinculación con existente verificada manualmente');
|
|
if (!reason || reason.trim().length < 5) return;
|
|
setSaving(true); setError('');
|
|
try {
|
|
const updated = await resolveAssetImportPlanItem(detail.id, matchItem.id, { action: 'MATCH', matchedAssetId: asset.id, reason: reason.trim() });
|
|
setPlan(updated); await loadReviews(1, reviewKind); setMatchItem(null); setMatchResults([]); setSuccess(`Se usará el registro existente ${asset.code} · ${asset.name}.`);
|
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const chooseContextCandidate = async (item: AssetImportPlanItem, candidate: InventoryContextCandidate) => {
|
|
if (!detail) return;
|
|
const label = `${candidate.name}${candidate.parentName ? ` · Área ${candidate.parentName}` : ''}`;
|
|
if (!window.confirm(`¿Confirmar “${label}” como contexto territorial para este inventario?
|
|
|
|
La elección se guarda sólo para este lote. Al confirmar, el sistema recalculará automáticamente la estructura local, padres y equipos dependientes.`)) return;
|
|
const reason = window.prompt('Motivo de la elección territorial:', 'Contexto territorial verificado contra la fuente');
|
|
if (!reason || reason.trim().length < 5) return;
|
|
setSaving(true); setError(''); setSuccess('');
|
|
try {
|
|
await resolveAssetImportPlanItem(detail.id, item.id, { action: 'MATCH', matchedAssetId: candidate.id, reason: reason.trim() });
|
|
const regenerated = await generateAssetImportPlan(detail.id, {
|
|
operatorAssetId: detail.profileCode === 'MENDOZA_INVENTORY_V1' ? operatorAssetId : undefined,
|
|
externalIdNamespace: detail.profileCode === 'MENDOZA_INVENTORY_V1' && externalNamespace.trim() ? externalNamespace.trim() : undefined,
|
|
});
|
|
setPlan(regenerated);
|
|
await loadBatches(detail.id);
|
|
await loadDetail(detail.id, rowMeta.page);
|
|
await loadReviews(1, reviewKind);
|
|
setSuccess(regenerated.status === 'READY'
|
|
? `Contexto confirmado: ${label}. El sistema recalculó las dependencias y el plan quedó listo para confirmar.`
|
|
: `Contexto confirmado: ${label}. El sistema recalculó las dependencias; quedan ${directReviewCount(regenerated).toLocaleString('es-AR')} decisión/es humana/s.`);
|
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const applySafePlan = async () => {
|
|
if (!detail || !plan || plan.status !== 'REVIEW_REQUIRED' || !canApply || plan.masterStateStale || summaryNumber(plan, 'safeCreateItems') <= 0) return;
|
|
const confirmation = window.prompt(`Se incorporarán únicamente los registros independientes que ya son seguros.
|
|
|
|
Listos para crear ahora: ${summaryNumber(plan, 'safeCreateItems').toLocaleString('es-AR')}
|
|
Ramas que esperan una revisión: ${summaryNumber(plan, 'blockedCreateItems').toLocaleString('es-AR')}
|
|
Decisiones pendientes: ${directReviewCount(plan).toLocaleString('es-AR')}
|
|
Dependencias pendientes: ${dependencyReviewCount(plan).toLocaleString('es-AR')}
|
|
|
|
Las ramas pendientes no se modificarán.
|
|
|
|
Escribí IMPORTAR para continuar:`);
|
|
if (confirmation !== 'IMPORTAR') return;
|
|
setSaving(true); setError(''); setSuccess('');
|
|
try {
|
|
const updated = await applySafeAssetImportPlan(detail.id, plan.planHash);
|
|
setPlan(updated); await loadBatches(detail.id); await loadDetail(detail.id, 1); await loadOrganizations(); await loadReviews(1, reviewKind);
|
|
setSuccess(`Importación parcial completada. Los registros seguros se incorporaron y las revisiones pendientes quedaron guardadas para resolver más adelante.`);
|
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const applyPlan = async () => {
|
|
if (!detail || !plan || plan.status !== 'READY' || !canApply || plan.masterStateStale) return;
|
|
const confirmation = window.prompt(`Esta acción incorporará registros al inventario en una única transacción.\n\nCrear nuevos: ${summaryNumber(plan, 'createItems').toLocaleString('es-AR')}\nUsar existentes: ${summaryNumber(plan, 'matchItems').toLocaleString('es-AR')}\nIgnorar: ${summaryNumber(plan, 'ignoreItems').toLocaleString('es-AR')}\n\nEscribí APLICAR para confirmar:`);
|
|
if (confirmation !== 'APLICAR') return;
|
|
setSaving(true); setError(''); setSuccess('');
|
|
try {
|
|
const applied = await applyAssetImportPlan(detail.id, plan.planHash);
|
|
setPlan(applied); await loadBatches(detail.id); await loadDetail(detail.id, 1); await loadOrganizations(); await loadReviews(1, reviewKind);
|
|
setSuccess('Importación aplicada transaccionalmente. Los registros y relaciones quedaron vinculados al lote de origen.');
|
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const rollbackPlan = async () => {
|
|
if (!detail || !plan || (plan.status !== 'APPLIED' && executionStatus(detail) !== 'PARTIAL') || !canApply) return;
|
|
const reason = window.prompt('Motivo de la reversión lógica del lote:');
|
|
if (!reason || reason.trim().length < 8) return;
|
|
if (!window.confirm('La reversión inactivará únicamente entidades creadas por este lote y se bloqueará si ya tienen uso posterior. ¿Continuar?')) return;
|
|
setSaving(true); setError(''); setSuccess('');
|
|
try {
|
|
const rolledBack = await rollbackAssetImportPlan(detail.id, reason.trim());
|
|
setPlan(rolledBack); await loadBatches(detail.id); await loadDetail(detail.id, 1); await loadOrganizations(); await loadReviews(1, reviewKind);
|
|
setSuccess('Lote revertido lógicamente. No se eliminaron registros históricos.');
|
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const cancel = async () => {
|
|
if (!detail || detail.status === 'CANCELLED' || plan?.status === 'APPLIED' || executionStatus(detail) === 'PARTIAL') return;
|
|
const reason = window.prompt('Motivo de cancelación del lote:');
|
|
if (!reason || reason.trim().length < 5) return;
|
|
setSaving(true); setError('');
|
|
try {
|
|
await cancelAssetImport(detail.id, reason.trim());
|
|
await loadBatches(detail.id); await loadDetail(detail.id);
|
|
setSuccess('Lote cancelado. El archivo queda conservado para auditoría.');
|
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const filteredIssueCount = useMemo(() => detail?.topIssues.reduce((sum, item) => sum + item.count, 0) ?? 0, [detail]);
|
|
const reconciliation = detail ? record(detail.analysis.reconciliation) : null;
|
|
const planByKind = plan ? record(plan.summary.byKind) : null;
|
|
const applied = plan?.status === 'APPLIED';
|
|
const planReady = plan?.status === 'READY';
|
|
const planMasterStale = Boolean(plan?.masterStateStale);
|
|
const directReviewItems = plan?.reviewItems.filter((item) => !isDependencyReview(item)) ?? [];
|
|
const dependencyReviewItems = plan?.reviewItems.filter((item) => isDependencyReview(item)) ?? [];
|
|
|
|
const execution = detail ? record(detail.analysis.importExecution) : null;
|
|
const partiallyApplied = execution?.status === 'PARTIAL';
|
|
const appliedCreateItems = Number(execution?.appliedCreateItems ?? plan?.summary.appliedCreateItems ?? 0);
|
|
const safeCreateItems = plan ? summaryNumber(plan, 'safeCreateItems') : 0;
|
|
const blockedCreateItems = plan ? summaryNumber(plan, 'blockedCreateItems') : 0;
|
|
|
|
if (loading) return <LoadingBlock label="Cargando importaciones…" />;
|
|
return <div className="page-stack import-center-page">
|
|
<div className="page-heading">
|
|
<div><span className="eyebrow">INVENTARIOS · DATOS REALES</span><h1>Centro de importaciones</h1><p>Convertí inventarios masivos en un plan auditable antes de modificar los inventarios.</p></div>
|
|
</div>
|
|
|
|
{error && <Alert>{error}</Alert>}
|
|
{success && <Alert type="success">{success}</Alert>}
|
|
<div className="import-section-tabs">
|
|
<button type="button" className={section === 'BATCHES' ? 'active' : ''} onClick={() => setSection('BATCHES')}><span>Lotes</span><b>{batches.length}</b></button>
|
|
<button type="button" className={section === 'REVIEWS' ? 'active' : ''} onClick={() => setSection('REVIEWS')}><span>Revisiones</span><b>{reviewMeta.total}</b></button>
|
|
</div>
|
|
|
|
{section === 'BATCHES' ? <>
|
|
<Alert type="info"><strong>Proceso de importación:</strong> las decisiones pendientes no frenan las ramas independientes. Podés importar lo seguro y dejar el resto en <strong>Revisiones</strong> para resolverlo más adelante.</Alert>
|
|
|
|
<div className="import-workflow import-workflow-six">
|
|
<span className="done">1 · Análisis</span>
|
|
<span className="done">2 · Normalización</span>
|
|
<span className={reconciliation ? 'done' : 'current'}>3 · Conciliación</span>
|
|
<span className={plan ? (plan.status === 'REVIEW_REQUIRED' ? 'current' : 'done') : reconciliation ? 'current' : ''}>4 · Revisiones</span>
|
|
<span className={partiallyApplied ? 'current' : applied ? 'done' : planReady ? 'current' : ''}>5 · Aplicación segura</span>
|
|
<span className={applied ? 'done' : ''}>6 · Cierre</span>
|
|
</div>
|
|
|
|
{canManage && <form className="panel import-upload-panel" onSubmit={analyze}>
|
|
<div className="panel-heading"><div><span className="eyebrow">NUEVO LOTE</span><h2>Analizar XLSX / CSV</h2></div><Icon name="layers" /></div>
|
|
<div className="import-upload-grid">
|
|
<label className="field"><span>Archivo</span><input type="file" accept=".xlsx,.csv" onChange={(event) => setFile(event.target.files?.[0] ?? null)} /></label>
|
|
<label className="field"><span>Fuente / empresa <em>opcional</em></span><input value={sourceLabel} onChange={(event) => setSourceLabel(event.target.value)} maxLength={200} placeholder="Phoenix, PSEnergy, EMESA…" /></label>
|
|
<label className="field"><span>Perfil <em>detección automática</em></span><SearchableSelect value={profileCode} onChange={(event) => setProfileCode(event.target.value as typeof profileCode)}><option value="">Detectar automáticamente</option><option value="MENDOZA_INVENTORY_V1">Inventario de instalaciones · Mendoza</option><option value="MENDOZA_YACIMIENTOS_V1">Tabla Área / Yacimiento · Mendoza</option></SearchableSelect></label>
|
|
<label className="field import-notes"><span>Notas <em>opcional</em></span><input value={notes} onChange={(event) => setNotes(event.target.value)} maxLength={2000} placeholder="Ej.: inventario recibido por Nota DH…" /></label>
|
|
</div>
|
|
<div className="form-actions"><button className="button primary" disabled={saving || !file}>{saving ? 'Analizando…' : 'Analizar archivo'}</button></div>
|
|
</form>}
|
|
|
|
<div className="import-center-grid">
|
|
<section className="table-panel import-batches-panel">
|
|
<div className="table-summary"><strong>Lotes analizados</strong><span>{batches.length} visibles</span></div>
|
|
{batches.length === 0 ? <EmptyState title="Todavía no hay importaciones" text="Subí uno de los inventarios reales para validar su estructura antes de incorporarlo al inventario correspondiente." /> : <div className="import-batch-list">
|
|
{batches.map((batch) => {
|
|
const state = batchStatus(batch);
|
|
return <button key={batch.id} className={`import-batch-item ${selectedId === batch.id ? 'selected' : ''}`} onClick={() => setSelectedId(batch.id)}>
|
|
<div><strong>{batch.originalName}</strong><span>{profileLabel(batch.profileCode)} · {batch.totalRows.toLocaleString('es-AR')} filas</span><small>{batch.sourceLabel || 'Fuente sin identificar'} · {new Date(batch.createdAt).toLocaleString('es-AR')}</small></div>
|
|
<span className={`status-badge ${state.className}`}>{state.label}</span>
|
|
</button>;
|
|
})}
|
|
</div>}
|
|
</section>
|
|
|
|
<section className="import-detail-column">
|
|
{!selectedId ? <EmptyState title="Seleccioná un lote" text="Elegí un archivo analizado para revisar su normalización y plan de importación." /> : detailLoading && !detail ? <LoadingBlock label="Cargando lote…" /> : detail && <>
|
|
<div className="panel import-summary-panel">
|
|
<div className="panel-heading"><div><span className="eyebrow">ANÁLISIS</span><h2>{detail.originalName}</h2></div><div className="form-actions">{canManage && detail.status !== 'CANCELLED' && !applied && !partiallyApplied && <button className="button secondary" disabled={saving} onClick={() => void reconcile()}>{saving ? 'Procesando…' : 'Conciliar con inventarios'}</button>}{canManage && detail.status !== 'CANCELLED' && !applied && !partiallyApplied && <button className="button danger-outline" disabled={saving} onClick={cancel}>Cancelar lote</button>}</div></div>
|
|
<div className="import-kpis">
|
|
<div><small>Filas</small><strong>{detail.totalRows.toLocaleString('es-AR')}</strong></div>
|
|
<div className="ok"><small>Analizables</small><strong>{detail.readyRows.toLocaleString('es-AR')}</strong></div>
|
|
<div className="warn"><small>Advertencias</small><strong>{detail.warningRows.toLocaleString('es-AR')}</strong></div>
|
|
<div className="bad"><small>Conflictos</small><strong>{detail.conflictRows.toLocaleString('es-AR')}</strong></div>
|
|
</div>
|
|
<div className="import-meta-grid">
|
|
<div><small>Perfil detectado</small><strong>{profileLabel(detail.profileCode)}</strong><span>Confianza {detail.profileConfidence}%</span></div>
|
|
<div><small>Hoja / encabezado</small><strong>{detail.worksheetName ?? '—'}</strong><span>Fila {detail.headerRow ?? '—'}</span></div>
|
|
<div><small>Archivo</small><strong>{formatBytes(detail.sizeBytes)}</strong><span>SHA {detail.sha256.slice(0, 12)}…</span></div>
|
|
</div>
|
|
{reconciliation && <div className="reconciliation-summary"><strong>Conciliación por filas</strong><div><span>Nuevas <b>{Number(reconciliation.createRows ?? 0).toLocaleString('es-AR')}</b></span><span>Ya existentes <b>{Number(reconciliation.matchRows ?? 0).toLocaleString('es-AR')}</b></span><span>Revisar <b>{Number(reconciliation.reviewRows ?? 0).toLocaleString('es-AR')}</b></span><span>Ignorar <b>{Number(reconciliation.ignoreRows ?? 0).toLocaleString('es-AR')}</b></span></div><small>Es una señal preliminar. El Plan siguiente deduplica Áreas, Yacimientos, Organizaciones, instalaciones y elementos antes de cualquier incorporación.</small></div>}
|
|
{detail.duplicateFiles.length > 0 && <Alert type="info">Este mismo archivo (mismo SHA-256) ya fue analizado {detail.duplicateFiles.length} vez/veces anteriormente.</Alert>}
|
|
{detail.topIssues.length > 0 && <div className="issue-cloud"><strong>Principales observaciones · {filteredIssueCount.toLocaleString('es-AR')}</strong><div>{detail.topIssues.map((issue) => <span key={issue.code}>{ISSUE_LABELS[issue.code] ?? issue.code} <b>{issue.count}</b></span>)}</div></div>}
|
|
</div>
|
|
|
|
{reconciliation && detail.status !== 'CANCELLED' && <div className="panel import-plan-panel">
|
|
<div className="panel-heading"><div><span className="eyebrow">PLAN DE IMPORTACIÓN</span><h2>Entidades únicas y operaciones</h2><p>Las filas repetidas se agrupan antes de crear Áreas, Yacimientos, relaciones e instalaciones.</p></div>{plan && (() => { const state = planState(plan); return <span className={`status-badge ${state.className}`}>{state.label}</span>; })()}</div>
|
|
|
|
{detail.profileCode === 'MENDOZA_INVENTORY_V1' && !applied && <div className="import-plan-context">
|
|
<label className="field"><span>Operadora del inventario <strong>obligatorio</strong></span><SearchableSelect value={operatorAssetId} onChange={(event) => setOperatorAssetId(event.target.value)}><option value="">Seleccionar organización…</option>{organizations.map((organization) => <option key={organization.id} value={organization.id}>{organization.name} · {organization.code}{organization.legalName && organization.legalName !== organization.name ? ` · ${organization.legalName}` : ''}</option>)}</SearchableSelect><small>Sólo se consideran Áreas con relación OPERATOR activa para esta organización.</small></label>
|
|
<label className="field"><span>Namespace de ID externo <em>opcional</em></span><input value={externalNamespace} onChange={(event) => setExternalNamespace(event.target.value.toUpperCase())} maxLength={80} placeholder="Ej.: PSENERGY" /><small>Evita tratar IDs de inventario de distintas fuentes como si fueran globales.</small></label>
|
|
</div>}
|
|
|
|
{!applied && canManage && <div className="form-actions import-plan-actions"><button className="button secondary" disabled={saving || (detail.profileCode === 'MENDOZA_INVENTORY_V1' && !operatorAssetId)} onClick={() => void generatePlan()}>{saving ? 'Generando…' : plan ? 'Regenerar plan' : 'Generar plan por entidades'}</button></div>}
|
|
|
|
{plan && <>
|
|
<div className="import-plan-kpis import-plan-kpi-actions">
|
|
<button type="button" className="ok" disabled={summaryNumber(plan, 'createItems') === 0} onClick={() => void loadPlanDetail(null, 1, 'CREATE')}><small>Crear nuevos</small><strong>{summaryNumber(plan, 'createItems').toLocaleString('es-AR')}</strong><span>No existen en los inventarios</span></button>
|
|
<button type="button" disabled={summaryNumber(plan, 'matchItems') === 0} onClick={() => void loadPlanDetail(null, 1, 'MATCH')}><small>Usar existentes</small><strong>{summaryNumber(plan, 'matchItems').toLocaleString('es-AR')}</strong><span>Ya existen; no se duplican</span></button>
|
|
<button type="button" className="bad" disabled={summaryNumber(plan, 'reviewItems') === 0} onClick={() => document.getElementById('import-review-panel')?.scrollIntoView({ behavior: 'smooth', block: 'start' })}><small>Decisiones obligatorias</small><strong>{directReviewCount(plan).toLocaleString('es-AR')}</strong><span>{dependencyReviewCount(plan) > 0 ? `${dependencyReviewCount(plan).toLocaleString('es-AR')} dependencias se recalculan solas` : 'Necesitan una decisión humana'}</span></button>
|
|
<button type="button" disabled={summaryNumber(plan, 'ignoreItems') === 0} onClick={() => void loadPlanDetail(null, 1, 'IGNORE')}><small>Ignorar</small><strong>{summaryNumber(plan, 'ignoreItems').toLocaleString('es-AR')}</strong><span>No se incorporan al inventario</span></button>
|
|
</div>
|
|
<div className="import-plan-meta"><span>Revisión <b>#{plan.revision}</b></span>{plan.externalIdNamespace && <span>Namespace <b>{plan.externalIdNamespace}</b></span>}<span>Hash <code>{plan.planHash.slice(0, 12)}…</code></span></div>
|
|
{summaryNumber(plan, 'explicitUnassignedOperatorFields') > 0 && <Alert>La fuente declara <strong>{summaryNumber(plan, 'explicitUnassignedOperatorFields').toLocaleString('es-AR')} Yacimientos sin empresa operadora</strong>. Se conservarán como borradores dentro de su Área, sin inventar una organización ni asignar contexto operativo.</Alert>}
|
|
|
|
{planByKind && <div className="import-plan-breakdown">
|
|
<div className="table-summary"><strong>Plan relacional por entidad</strong><span>Las filas se convierten en entidades únicas y relaciones; no se duplican catálogos.</span></div>
|
|
{summaryNumber(plan, 'relationalTerritoryPlan') > 0 && <div className="import-plan-kpis compact">
|
|
<div><small>Departamentos únicos</small><strong>{summaryNumber(plan, 'normalizedDepartments').toLocaleString('es-AR')}</strong></div>
|
|
<div><small>Tipos de derecho</small><strong>{summaryNumber(plan, 'normalizedLegalRightTypes').toLocaleString('es-AR')}</strong></div>
|
|
<div><small>Área ↔ Departamento</small><strong>{summaryNumber(plan, 'areaDepartmentRelations').toLocaleString('es-AR')}</strong></div>
|
|
<div><small>Derechos / concesiones</small><strong>{summaryNumber(plan, 'legalRights').toLocaleString('es-AR')}</strong></div>
|
|
</div>}
|
|
{PLAN_GROUPS.map((group) => {
|
|
const visibleKinds = group.kinds.filter((kind) => Number(record(planByKind[kind])?.total ?? 0) > 0);
|
|
if (!visibleKinds.length) return null;
|
|
return <section key={group.label} className="import-plan-group"><h4>{group.label}</h4><div className="table-scroll"><table><thead><tr><th>Entidad</th><th>Crear nuevos</th><th>Usar existentes</th><th>Revisar</th><th>Ignorar</th><th>Total</th><th></th></tr></thead><tbody>{visibleKinds.map((kind) => { const counts = record(planByKind[kind])!; return <tr key={kind}><td><strong>{ENTITY_LABELS[kind]}</strong></td><td>{Number(counts.create ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.match ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.review ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.ignore ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.total ?? 0).toLocaleString('es-AR')}</td><td><button type="button" className="button secondary small" onClick={() => void loadPlanDetail(kind, 1, null)}>Ver detalle</button></td></tr>; })}</tbody></table></div></section>;
|
|
})}
|
|
{(planDetailKind || planDetailAction) && <div className="import-plan-detail"><div className="table-summary"><strong>{planDetailKind ? ENTITY_LABELS[planDetailKind] : planDetailAction ? PLAN_ACTION_LABELS[planDetailAction] : 'Detalle del plan'}</strong><span>{planDetailMeta.total.toLocaleString('es-AR')} elementos únicos</span></div>{planDetailLoading ? <LoadingBlock label="Cargando detalle del plan…" /> : <><div className="table-scroll"><table><thead><tr><th>Decisión</th><th>Entidad / relación</th><th>Qué hará el sistema</th><th>Filas fuente</th></tr></thead><tbody>{planDetailItems.map((item) => { const objectId = matchedObjectId(item); return <tr key={item.id}><td><span className={`status-badge ${item.action === 'REVIEW' ? 'observed' : item.action === 'CREATE' ? 'active' : item.action === 'MATCH' ? 'pending' : 'inactive'}`}>{PLAN_ACTION_LABELS[item.action]}</span></td><td><strong>{item.displayName}</strong><small className="cell-subtle">{ENTITY_LABELS[item.entityKind]}</small>{item.entityKind === 'DEPARTMENT' && <small className="cell-subtle">Código {String(item.payload.departmentCode ?? '—')}</small>}{item.entityKind === 'LEGAL_RIGHT' && <small className="cell-subtle">{String(item.payload.rightType ?? '—')} · estado inicial PENDING</small>}</td><td>{item.action === 'MATCH' ? <div className="import-match-explanation"><strong>Se reutiliza un registro del inventario</strong><span>{MATCH_CRITERIA[item.entityKind]}</span>{objectId && <code>ID {objectId.slice(0, 8)}…</code>}</div> : item.action === 'CREATE' ? <div className="import-match-explanation"><strong>Se creará un registro nuevo</strong><span>No se encontró una coincidencia exacta y segura con las reglas de este tipo.</span></div> : item.action === 'REVIEW' ? <div className="row-issue-list">{item.reviewCodes.slice(0, 3).map((code) => <span key={code}>{reviewGuidance(code).title}</span>)}</div> : <span className="cell-subtle">No se incorporará al inventario.</span>}</td><td>{sourceRowsLabel(item.sourceRowNumbers)}</td></tr>; })}</tbody></table></div>{planDetailMeta.totalPages > 1 && <div className="pagination"><button type="button" disabled={planDetailMeta.page <= 1} onClick={() => void loadPlanDetail(planDetailKind, planDetailMeta.page - 1, planDetailAction)}>Anterior</button><span>Página {planDetailMeta.page} de {planDetailMeta.totalPages}</span><button type="button" disabled={planDetailMeta.page >= planDetailMeta.totalPages} onClick={() => void loadPlanDetail(planDetailKind, planDetailMeta.page + 1, planDetailAction)}>Siguiente</button></div>}</>}</div>}
|
|
</div>}
|
|
|
|
{plan.status === 'REVIEW_REQUIRED' && <div id="import-review-panel" className="import-review-panel">
|
|
<div className="import-review-mandatory-banner">
|
|
<div><span className="eyebrow">REVISIONES PENDIENTES</span><strong>Podés continuar con los registros independientes y decidir esto más adelante</strong><p>Las ramas que dependan de una decisión quedan pendientes. El resto puede incorporarse al inventario sin asumir datos ni jerarquías.</p></div>
|
|
<div className="import-review-lock"><Icon name="shield" /><span>Ramas pendientes protegidas</span><small>Las decisiones no se aplican hasta resolverlas</small></div>
|
|
</div>
|
|
<div className="import-review-steps">
|
|
<div className="current"><span>1</span><strong>Resolver decisiones</strong><small>{directReviewCount(plan).toLocaleString('es-AR')} pendientes</small></div>
|
|
<div><span>2</span><strong>Recalcular dependencias</strong><small>{dependencyReviewCount(plan).toLocaleString('es-AR')} automáticas</small></div>
|
|
<div><span>3</span><strong>Confirmar importación</strong><small>Sólo con plan limpio</small></div>
|
|
</div>
|
|
<Alert type="info"><strong>No resuelvas instalaciones o padres derivados.</strong> Si varias filas dependen del mismo Área/Yacimiento, elegís ese contexto una sola vez. El sistema vuelve a generar el plan y recalcula estructura local, padres y equipos.</Alert>
|
|
<Alert><strong>Nomenclatura de la operadora preservada.</strong> BATERIA, SET, PTC y otros nombres propios no se convierten en categorías universales de DH. Se conservan como <em>Estructura local según fuente</em>.</Alert>
|
|
|
|
<div className="import-review-overview">
|
|
<div className="human"><small>Vos tenés que decidir</small><strong>{directReviewCount(plan).toLocaleString('es-AR')}</strong><span>Decisiones humanas obligatorias</span></div>
|
|
<div className="automatic"><small>El sistema resuelve después</small><strong>{dependencyReviewCount(plan).toLocaleString('es-AR')}</strong><span>Dependencias automáticas</span></div>
|
|
<div className="import-review-reason-summary"><small>Motivos detectados</small><div>{Array.from(plan.reviewItems.reduce((map, item) => { item.reviewCodes.forEach((code) => map.set(code, (map.get(code) ?? 0) + 1)); return map; }, new Map<string, number>()).entries()).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([code, count]) => <span key={code}>{reviewGuidance(code).title} <b>{count}</b></span>)}</div></div>
|
|
</div>
|
|
|
|
{directReviewItems.length === 0 ? <Alert type="info"><strong>No quedan decisiones humanas visibles en este plan.</strong> Si todavía aparecen dependencias, regenerá el plan para que el sistema las recalcule sobre las decisiones ya tomadas.</Alert> : <>
|
|
<div className="import-review-section-heading"><div><span className="eyebrow">ACCIÓN REQUERIDA</span><strong>Estas son las únicas tarjetas que tenés que resolver</strong></div><span>{directReviewItems.length.toLocaleString('es-AR')} mostradas</span></div>
|
|
<div className="import-review-list direct-only">{directReviewItems.map((item) => {
|
|
const facts = reviewFacts(item);
|
|
const provisional = isProvisionalName(item.displayName);
|
|
const contextCandidates = inventoryContextCandidates(item);
|
|
const contextDecision = item.payload.inventoryContextDecision === true;
|
|
return <article key={item.id} className="import-review-item decision">
|
|
<div className="import-review-copy">
|
|
<div className="import-review-header">
|
|
<span className="status-badge observed">{contextDecision ? 'Elegir contexto territorial' : 'Decisión humana requerida'}</span>
|
|
<span className="eyebrow">{ENTITY_LABELS[item.entityKind]} · {sourceRowsLabel(item.sourceRowNumbers)}</span>
|
|
</div>
|
|
<strong className="import-review-title">{provisionalDisplayName(item.displayName)}</strong>
|
|
{provisional && <span className="import-review-provisional">Nombre provisional de un plan anterior. Regenerá con la versión actual para preservar la nomenclatura local.</span>}
|
|
{facts.length > 0 && <div className="import-review-source-box"><strong>1 · Qué informa el archivo</strong><dl className="import-review-facts">{facts.map(([label, current]) => <div key={label}><dt>{label}</dt><dd>{current}</dd></div>)}</dl></div>}
|
|
<div className="import-review-reasons"><div><strong>2 · Por qué el sistema no decide solo</strong>{item.reviewCodes.map((code) => { const guidance = reviewGuidance(code); return <p key={code}><b>{guidance.title}.</b> {guidance.detail}</p>; })}</div></div>
|
|
</div>
|
|
<div className="import-review-resolution">
|
|
<strong>3 · Qué tenés que decidir</strong>
|
|
{contextDecision ? <>
|
|
<p>El nombre de la fuente puede corresponder a más de un lugar. Compará el Área padre y elegí <b>una sola alternativa</b>. No se modifica el inventario al elegir.</p>
|
|
<div className="import-context-candidates">{contextCandidates.map((candidate) => <button type="button" key={candidate.id} className="import-context-candidate" disabled={saving} onClick={() => void chooseContextCandidate(item, candidate)}><span className="import-context-choice-copy"><strong>{candidate.name}</strong><span>{candidate.parentName ? `Área: ${candidate.parentName}` : candidate.typeCode === 'area' ? 'Área' : 'Yacimiento'} · Código ${candidate.code}</span></span><span className="import-context-choice-action">Elegir este</span></button>)}</div>
|
|
{contextCandidates.length === 0 && <p className="cell-subtle">No quedaron alternativas válidas. Regenerá el plan o revisá el inventario.</p>}
|
|
<small className="import-review-after-choice"><b>Después:</b> el plan se regenera automáticamente y recalcula todos los registros dependientes.</small>
|
|
</> : <>
|
|
<p>{item.reviewCodes.map((code) => reviewGuidance(code).decision).join(' ')}</p>
|
|
<div className="form-actions">{item.payload.manualMatchAllowed !== false && item.assetTypeCode && item.entityKind !== 'OPERATOR_RELATION' && <button className="button secondary" disabled={saving} onClick={() => void searchMatch(item)}>Usar un registro existente</button>}{item.payload.manualCreateAllowed === true && Boolean(item.assetTypeCode) && <button className="button secondary" disabled={saving} onClick={() => void resolveReview(item, 'CREATE', 'Creación aceptada manualmente')}>Crear uno nuevo</button>}{item.entityKind === 'TECHNICAL_ASSET' && <button className="button danger-outline" disabled={saving} onClick={() => void resolveReview(item, 'IGNORE', 'Registro excluido manualmente de la importación')}>No importar esta fila</button>}</div>
|
|
</>}
|
|
</div>
|
|
</article>;
|
|
})}</div>
|
|
</>}
|
|
|
|
{dependencyReviewItems.length > 0 && <details className="import-review-dependencies">
|
|
<summary><span><strong>{dependencyReviewCount(plan).toLocaleString('es-AR')} dependencias automáticas</strong><small>No requieren una decisión individual</small></span><span>Ver detalle</span></summary>
|
|
<div className="import-dependency-list">{dependencyReviewItems.map((item) => <div key={item.id}><div><strong>{provisionalDisplayName(item.displayName)}</strong><span>{ENTITY_LABELS[item.entityKind]} · {sourceRowsLabel(item.sourceRowNumbers)}</span></div><small>{item.reviewCodes.map((code) => reviewGuidance(code).title).join(' · ')}</small></div>)}</div>
|
|
</details>}
|
|
</div>}
|
|
|
|
{matchItem && <div className="import-match-picker"><div className="table-summary"><strong>Vincular “{provisionalDisplayName(matchItem.displayName)}” con un registro existente</strong><button className="button secondary" onClick={() => { setMatchItem(null); setMatchResults([]); }}>Cerrar</button></div><div className="search-field"><Icon name="search" /><input value={matchSearch} onChange={(event) => setMatchSearch(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); void searchMatch(matchItem, matchSearch); } }} placeholder="Código o nombre en inventarios…" /><button onClick={() => void searchMatch(matchItem, matchSearch)}>Buscar</button></div>{matchLoading ? <LoadingBlock label="Buscando registros…" /> : matchResults.length === 0 ? <p className="cell-subtle">No se encontraron registros compatibles en los primeros resultados. Probá otro código o nombre.</p> : <div className="import-match-results">{matchResults.map((asset) => <button key={asset.id} onClick={() => void chooseMatch(asset)}><strong>{asset.code} · {asset.name}</strong><span>{asset.type.name}{asset.parent ? ` · ${asset.parent.name}` : ''}</span></button>)}</div>}</div>}
|
|
|
|
{partiallyApplied && <Alert type="info"><strong>Importación parcial en curso.</strong> Ya se incorporaron <strong>{appliedCreateItems.toLocaleString('es-AR')}</strong> entidades del lote. Las revisiones y sus dependencias siguen pendientes y pueden resolverse desde la pestaña <strong>Revisiones</strong>.</Alert>}
|
|
{planReady && !planMasterStale && <Alert type="success">Plan listo: no quedan decisiones pendientes. La confirmación valida tanto el hash del plan como el estado de los inventarios antes de escribir.</Alert>}
|
|
{planReady && planMasterStale && <Alert type="info"><strong>Plan obsoleto.</strong> El inventario cambió desde que se generó esta revisión. Conciliá y regenerá el plan antes de aplicar para evitar duplicados.</Alert>}
|
|
{plan.status === 'APPLIED' && <Alert type="success">Importación aplicada el {plan.appliedAt ? new Date(plan.appliedAt).toLocaleString('es-AR') : '—'}. El lote permanece trazable y puede revertirse lógicamente mientras no existan dependencias posteriores.</Alert>}
|
|
{plan.status === 'ROLLED_BACK' && <Alert type="info">La importación fue revertida lógicamente. Las entidades creadas quedaron inactivas y se conservó su historial.</Alert>}
|
|
|
|
<div className="form-actions import-apply-actions">
|
|
{plan.status === 'REVIEW_REQUIRED' && canApply && safeCreateItems > 0 && <button className="button primary" disabled={saving || planMasterStale} onClick={() => void applySafePlan()}>{saving ? 'Importando…' : planMasterStale ? 'Regenerá el plan antes de importar' : 'Importar registros seguros'}</button>}
|
|
{plan.status === 'REVIEW_REQUIRED' && canApply && safeCreateItems === 0 && <span className="cell-subtle">No quedan ramas independientes para importar. {blockedCreateItems.toLocaleString('es-AR')} registros esperan una revisión.</span>}
|
|
{planReady && canApply && <button className="button primary" disabled={saving || planMasterStale} onClick={() => void applyPlan()}>{saving ? 'Aplicando…' : planMasterStale ? 'Regenerá el plan antes de aplicar' : 'Confirmar y completar importación'}</button>}
|
|
{(planReady || plan.status === 'REVIEW_REQUIRED') && !canApply && <span className="cell-subtle">Sólo Director/Admin con permiso <code>asset_imports.apply</code> puede aplicar.</span>}
|
|
{(plan.status === 'APPLIED' || partiallyApplied) && canApply && <button className="button danger-outline" disabled={saving} onClick={() => void rollbackPlan()}>{partiallyApplied ? 'Revertir importación parcial' : 'Revertir lote'}</button>}
|
|
</div>
|
|
</>}
|
|
</div>}
|
|
|
|
<div className="toolbar import-row-toolbar">
|
|
<label className="select-field"><span>Resultado</span><SearchableSelect value={rowStatusFilter} onChange={(event) => setRowStatusFilter(event.target.value as AssetImportRowStatus | '')}><option value="">Todos</option><option value="READY">Listos</option><option value="WARNING">Advertencias</option><option value="CONFLICT">Conflictos</option><option value="IGNORED">Ignorados</option></SearchableSelect></label>
|
|
<div className="search-field"><Icon name="search" /><input value={rowSearch} onChange={(event) => setRowSearch(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); void loadDetail(detail.id, 1); } }} placeholder="Buscar ID, equipo, ubicación…" /><button onClick={() => void loadDetail(detail.id, 1)}>Buscar</button></div>
|
|
</div>
|
|
|
|
<div className="table-panel import-rows-table">
|
|
<div className="table-summary"><strong>Vista previa normalizada</strong><span>{rowMeta.total.toLocaleString('es-AR')} filas</span></div>
|
|
<div className="table-scroll"><table>
|
|
{detail.profileCode === 'MENDOZA_YACIMIENTOS_V1' ? <>
|
|
<thead><tr><th>Fila</th><th>Resultado</th><th>Área</th><th>Yacimiento</th><th>Departamento</th><th>Derecho</th><th>Operadora</th><th>Conciliación</th><th>Observaciones</th></tr></thead>
|
|
<tbody>{rows.map((row) => { const state = rowStatus(row.status); const rec = record(row.normalizedData.reconciliation); return <tr key={row.id}>
|
|
<td>{row.rowNumber}</td><td><span className={`status-badge ${state.className}`}>{state.label}</span></td>
|
|
<td><strong>{value(row.normalizedData, 'area')}</strong></td><td>{value(row.normalizedData, 'field')}</td><td>{value(row.normalizedData, 'department')}</td><td>{value(row.normalizedData, 'rightType')}</td><td>{value(row.normalizedData, 'operator')}</td>
|
|
<td>{row.importedAssetId ? <span className="ok-text">Importado</span> : row.matchedAssetId ? <span className="ok-text">Ya existe</span> : rec ? <span>{row.suggestedAction === 'REVIEW' || Number(rec.candidateCount ?? 0) > 1 ? 'Revisar' : 'Nuevo'}</span> : <span className="cell-subtle">Sin conciliar</span>}</td>
|
|
<td><div className="row-issue-list">{row.issues.length ? row.issues.slice(0, 3).map((issue) => <span key={issue}>{ISSUE_LABELS[issue] ?? issue}</span>) : <span className="ok-text">Sin observaciones</span>}</div></td>
|
|
</tr>; })}</tbody>
|
|
</> : <>
|
|
<thead><tr><th>Fila</th><th>Resultado</th><th>Área / Yacimiento</th><th>Instalación / sector</th><th>Equipo</th><th>Normalización</th><th>ID inventario</th><th>Cant.</th><th>Estado fuente</th><th>Estado sugerido</th><th>Conciliación</th><th>Observaciones</th></tr></thead>
|
|
<tbody>{rows.map((row) => { const state = rowStatus(row.status); const rec = record(row.normalizedData.reconciliation); return <tr key={row.id}>
|
|
<td>{row.rowNumber}</td><td><span className={`status-badge ${state.className}`}>{state.label}</span></td>
|
|
<td>{value(row.normalizedData, 'areaOrField')}</td>
|
|
<td><strong>{value(row.normalizedData, 'installation')}</strong><small className="cell-subtle">{value(row.normalizedData, 'subInstallation')}</small></td>
|
|
<td><strong>{value(row.normalizedData, 'equipment')}</strong><small className="cell-subtle">{value(row.normalizedData, 'sourceClassification')}</small></td>
|
|
<td><strong>{value(row.normalizedData, 'normalizedFamily')}</strong><small className="cell-subtle">{value(row.normalizedData, 'normalizedSubtype')}</small></td>
|
|
<td>{value(row.normalizedData, 'inventoryId')}</td><td>{value(row.normalizedData, 'quantity')}</td><td>{value(row.normalizedData, 'sourceStatus')}</td>
|
|
<td><strong>{value(row.normalizedData, 'operationalStatusSuggestion')}</strong><small className="cell-subtle">{value(row.normalizedData, 'conditionStatusSuggestion')}</small></td>
|
|
<td>{row.importedAssetId ? <span className="ok-text">Importado</span> : row.matchedAssetId ? <span className="ok-text">Ya existe</span> : rec ? <span>{row.suggestedAction === 'REVIEW' || Number(rec.candidateCount ?? 0) > 1 ? 'Revisar' : 'Nuevo'}</span> : <span className="cell-subtle">Sin conciliar</span>}</td>
|
|
<td><div className="row-issue-list">{row.issues.length ? row.issues.slice(0, 3).map((issue) => <span key={issue}>{ISSUE_LABELS[issue] ?? issue}</span>) : <span className="ok-text">Sin observaciones</span>}{row.issues.length > 3 && <small>+{row.issues.length - 3}</small>}</div></td>
|
|
</tr>; })}</tbody>
|
|
</>}
|
|
</table></div>
|
|
<div className="pagination"><button className="button secondary" disabled={rowMeta.page <= 1 || detailLoading} onClick={() => void loadDetail(detail.id, rowMeta.page - 1)}>Anterior</button><span>Página {rowMeta.page} de {Math.max(rowMeta.totalPages, 1)}</span><button className="button secondary" disabled={rowMeta.page >= rowMeta.totalPages || detailLoading} onClick={() => void loadDetail(detail.id, rowMeta.page + 1)}>Siguiente</button></div>
|
|
</div>
|
|
</>}
|
|
</section>
|
|
</div>
|
|
</> : <AssetImportReviewsPanel
|
|
data={reviews}
|
|
loading={reviewsLoading}
|
|
kind={reviewKind}
|
|
meta={reviewMeta}
|
|
onKindChange={(kind) => { setReviewKind(kind); void loadReviews(1, kind); }}
|
|
onPageChange={(page) => void loadReviews(page, reviewKind)}
|
|
onOpenBatch={(batchId) => { setSection('BATCHES'); setScrollToReviews(true); setSelectedId(batchId); }}
|
|
/>}
|
|
</div>;
|
|
}
|