Compare commits

..
Author SHA1 Message Date
admin 3d8ace5529 F6.1 · actualizar contrato de APK de prueba 2026-09-09 14:11:01 -03:00
admin 47203ff0da F6.1 · contrato de catálogo contextual en Yacimientos 2026-09-09 14:10:50 -03:00
admin b701becab7 F6.1 · usar catálogo contextual por tipo en Yacimientos 2026-09-09 14:10:20 -03:00
admin 9983201de7 F6.1 · actualizar contrato de health metadata 2026-09-09 14:04:26 -03:00
admin 4fc05b73b1 F6.1 · artefacto APK 0.15.0 2026-09-09 14:00:34 -03:00
admin 40cfeff42e F6.1 · Android 0.15.0 2026-09-09 14:00:24 -03:00
admin e3f89f4d5a F6.1 · metadata API 2026-09-09 14:00:11 -03:00
admin 7284c252ff F6.1 · version API 0.29.0-1 2026-09-09 14:00:01 -03:00
admin 7baf306a23 F6.1 · contratos Hallazgos y apertura móvil 2026-09-09 13:59:49 -03:00
admin 27a51c2fe2 F6.1 · tratar Yacimiento como objetivo de Hallazgos 2026-09-09 13:59:33 -03:00
admin b326eb737d F6.1 · permitir Hallazgos sobre Yacimientos 2026-09-09 13:59:07 -03:00
admin 6a5db887c0 F6.1 · abrir inspecciones desde APK 2026-09-09 13:58:23 -03:00
admin baf63a861c F6.1 · usar home móvil con apertura de inspecciones 2026-09-09 13:57:57 -03:00
admin d61e4ad04d F6.1 · pantalla APK para abrir inspección en campo 2026-09-09 13:57:34 -03:00
admin 37885c0454 F6.1 · cliente Android para abrir inspecciones 2026-09-09 13:57:08 -03:00
admin b6707cef43 F6.1 · contrato para abrir inspección desde APK 2026-09-09 13:56:28 -03:00
admin 74fc08bf24 F6 hotfix · deploy espera versión candidata (#30)
Endurece el healthcheck del watcher para esperar status ok, versión exacta candidata y database ok antes de aceptar el recreate.
2026-09-09 11:34:37 -03:00
admin ad7ec2ca2b fix(deploy): wait for exact candidate health version 2026-09-09 11:28:53 -03:00
admin d938db7913 F6 · Modelo definitivo de Inventarios + APK Android (#29)
Jerarquía física definitiva, compatibilidades N↔N de clasificaciones, Hallazgos y campos técnicos por familia, navegación WEB/Android y APK F6 0.14.0-debug.
2026-09-09 11:19:12 -03:00
admin 0f4bb13fc7 docs(f6): documentar modelo definitivo 2026-09-09 09:11:16 -03:00
admin f0db23bc9b docs(f6): ampliar contrato definitivo 2026-09-09 09:11:08 -03:00
admin 0b0851d9ec docs(f6): fijar contrato definitivo de Inventarios 2026-09-09 09:11:00 -03:00
admin 7338f277ad F5.1 · Inventario limpio, jerarquía manual y Hallazgos filtrados (#28)
Reset controlado de datos operativos e históricos, nueva jerarquía manual Departamento → Área → Yacimiento → Instalación → Subinstalación, filtros y administración inline de Hallazgos, y corrección de alta con Área sin Operadora.
2026-09-09 08:55:01 -03:00
38 changed files with 1850 additions and 711 deletions
+4 -3
View File
@@ -1,5 +1,5 @@
name: Android APK
# F5: genera una APK debug verificable contra la API productiva F5.
# F6.1: genera una APK debug verificable contra la API productiva F6.1.
on:
push:
@@ -11,6 +11,7 @@ on:
- 'feature/f2-4*'
- 'feature/f3-1*'
- 'feature/f3-2*'
- 'feature/f6-1*'
paths:
- 'android-app/**'
- '.github/workflows/android.yml'
@@ -62,7 +63,7 @@ jobs:
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: DH-Inspeccion-F5-0.13.0-debug
name: DH-Inspeccion-F6.1-0.15.0-debug
path: android-app/app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error
retention-days: 30
retention-days: 30
+3 -3
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 20
versionName = "0.13.0"
versionCode = 22
versionName = "0.15.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -77,4 +77,4 @@ dependencies {
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
}
}
@@ -49,6 +49,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
var inventory: List<FieldInventoryItem> by mutableStateOf(emptyList())
private set
var inventoryParentId: String? by mutableStateOf(null)
private set
var fieldTypes: List<FieldType> by mutableStateOf(emptyList())
private set
var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null)
@@ -96,6 +98,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
visits = emptyList()
visit = null
inventory = emptyList()
inventoryParentId = null
fieldTypes = emptyList()
selectedFieldAsset = null
clearActState()
@@ -112,6 +115,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun openVisit(id: String) = launchBusy {
visit = repository.visit(id)
inventory = emptyList()
inventoryParentId = visit?.operationalArea?.id
fieldTypes = emptyList()
selectedFieldAsset = null
clearFindingState()
@@ -121,6 +125,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun closeVisitView() {
visit = null
inventory = emptyList()
inventoryParentId = null
fieldTypes = emptyList()
selectedFieldAsset = null
clearActState()
@@ -132,6 +137,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val id = visit?.id ?: return
launchBusy {
visit = repository.startVisit(id)
inventoryParentId = visit?.operationalArea?.id
notice = "Inspección iniciada."
loadActsInternal(id, selectDraft = true)
loadVisitsInternal()
@@ -189,25 +195,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
fun searchInventory(search: String, parentId: String? = null) {
val id = visit?.id ?: return
val currentVisit = visit ?: return
launchBusy {
inventory = repository.fieldInventory(id, search, parentId).data
val effectiveParentId = parentId ?: inventoryParentId ?: currentVisit.operationalArea?.id
inventory = repository.fieldInventory(currentVisit.id, search, effectiveParentId).data
}
}
fun loadFieldTypes(parentId: String? = null) {
val id = visit?.id ?: return
val currentVisit = visit ?: return
launchBusy {
fieldTypes = repository.fieldTypes(id, parentId).data
val effectiveParentId = parentId ?: currentVisit.operationalArea?.id
inventoryParentId = effectiveParentId
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
}
}
private suspend fun reloadCurrentInventory(visitId: String) {
val effectiveParentId = inventoryParentId ?: visit?.operationalArea?.id
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
}
fun selectExisting(item: FieldInventoryItem) {
val visitId = visit?.id ?: return
launchBusy {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la Inspección."
inventory = repository.fieldInventory(visitId, null, null).data
reloadCurrentInventory(visitId)
val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, item.id)
@@ -236,7 +251,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return
}
if (type.familyRequired && familyId == null) {
error = "Elegí una familia técnica o la opción Otro / no catalogado."
error = "Elegí una clasificación técnica o la opción Otro / no catalogado."
return
}
launchBusy {
@@ -254,7 +269,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
)
selectedFieldAsset = repository.createFieldAsset(visitId, request)
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
inventory = repository.fieldInventory(visitId, null, null).data
reloadCurrentInventory(visitId)
}
}
@@ -278,7 +293,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
)
selectedFieldAsset = repository.selectFieldAsset(visitId, result.canonical.id)
notice = "Fusión registrada. Se conserva ${result.canonical.code} y la historia de ${result.source.code} permanece trazable."
inventory = repository.fieldInventory(visitId, null, null).data
reloadCurrentInventory(visitId)
val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id)
@@ -311,7 +326,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} else {
"Fotografía registrada."
}
inventory = repository.fieldInventory(visitId, null, null).data
reloadCurrentInventory(visitId)
val draft = selectedDraftAct()
if (response.capture.readyForFinding && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, asset.id)
@@ -0,0 +1,103 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
data class MobilePlanningAsset(
val id: String,
val code: String,
val name: String,
)
data class MobilePlanningAssetResponse(
val data: List<MobilePlanningAsset> = emptyList(),
)
data class OpenMobileInspectionRequest(
val operationalAreaId: String,
val operatorCompanyId: String,
)
private interface MobileInspectionOpenApi {
@GET("inspection-visits/mobile/planning-context/areas")
suspend fun areas(
@Header("Authorization") authorization: String,
): MobilePlanningAssetResponse
@GET("inspection-visits/mobile/planning-context/areas/{areaId}/operators")
suspend fun operators(
@Header("Authorization") authorization: String,
@Path("areaId") areaId: String,
): MobilePlanningAssetResponse
@POST("inspection-visits/mobile/open")
suspend fun open(
@Header("Authorization") authorization: String,
@Body request: OpenMobileInspectionRequest,
): VisitDetail
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
}
class MobileInspectionOpenRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileInspectionOpenApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(MobileInspectionOpenApi::class.java)
suspend fun areas(): MobilePlanningAssetResponse = authorized { session ->
api.areas("Bearer ${session.accessToken}")
}
suspend fun operators(areaId: String): MobilePlanningAssetResponse = authorized { session ->
api.operators("Bearer ${session.accessToken}", areaId)
}
suspend fun open(areaId: String, companyId: String): VisitDetail = authorized { session ->
api.open(
"Bearer ${session.accessToken}",
OpenMobileInspectionRequest(areaId, companyId),
)
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
}
@@ -92,7 +92,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
)
model.fieldFindingOptions != null -> FieldFindingScreen(model)
model.visit != null -> F3VisitRoot(model)
else -> DhApp(model)
else -> MobileHomeScreen(model)
}
}
}
@@ -0,0 +1,231 @@
package com.korexlabs.dhinspeccion.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.DhRepository
import com.korexlabs.dhinspeccion.data.MobileInspectionOpenRepository
import com.korexlabs.dhinspeccion.data.MobilePlanningAsset
import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.launch
@Composable
fun MobileHomeScreen(model: MainViewModel) {
val session = model.session ?: return
val context = LocalContext.current
val scope = rememberCoroutineScope()
val openRepository = remember(context) { MobileInspectionOpenRepository(context) }
var showOpen by rememberSaveable { mutableStateOf(false) }
var areas by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var operators by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var selectedAreaId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedCompanyId by rememberSaveable { mutableStateOf<String?>(null) }
var loadingContext by remember { mutableStateOf(false) }
var opening by remember { mutableStateOf(false) }
var localError by remember { mutableStateOf<String?>(null) }
fun loadAreas() {
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.areas().data }
.onSuccess { areas = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
fun chooseArea(areaId: String) {
selectedAreaId = areaId
selectedCompanyId = null
operators = emptyList()
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.operators(areaId).data }
.onSuccess { operators = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
LaunchedEffect(showOpen) {
if (showOpen && areas.isEmpty()) loadAreas()
}
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(session.displayName, style = MaterialTheme.typography.bodySmall)
}
Row {
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy && !opening) {
Text("Actualizar")
}
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = { model.logout() }, enabled = !opening) { Text("Salir") }
}
}
Column(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(
onClick = {
showOpen = !showOpen
localError = null
},
modifier = Modifier.fillMaxWidth(),
enabled = !opening,
) {
Text(if (showOpen) "Cancelar nueva inspección" else "Abrir inspección")
}
if (showOpen) {
Card(Modifier.fillMaxWidth()) {
Column(
Modifier.fillMaxWidth().padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Abrir inspección en campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Elegí el Área y la Operadora vigente. La inspección se crea autoasignada a vos y queda iniciada con la fecha y hora del servidor.",
style = MaterialTheme.typography.bodySmall,
)
Text("1. Área", fontWeight = FontWeight.SemiBold)
if (loadingContext && areas.isEmpty()) CircularProgressIndicator()
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(areas, key = { it.id }) { area ->
AssistChip(
onClick = { chooseArea(area.id) },
label = { Text(if (area.id == selectedAreaId) "${area.name}" else area.name) },
)
}
}
if (selectedAreaId != null) {
Text("2. Operadora", fontWeight = FontWeight.SemiBold)
if (loadingContext && operators.isEmpty()) {
CircularProgressIndicator()
} else if (operators.isEmpty()) {
Text("No hay una Operadora vigente para el Área seleccionada.", color = MaterialTheme.colorScheme.error)
}
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(operators, key = { it.id }) { company ->
AssistChip(
onClick = { selectedCompanyId = company.id },
label = { Text(if (company.id == selectedCompanyId) "${company.name}" else company.name) },
)
}
}
}
Button(
onClick = {
val areaId = selectedAreaId ?: return@Button
val companyId = selectedCompanyId ?: return@Button
scope.launch {
opening = true
localError = null
runCatching { openRepository.open(areaId, companyId) }
.onSuccess { opened ->
showOpen = false
model.openVisit(opened.id)
}
.onFailure { localError = DhRepository.humanError(it) }
opening = false
}
},
modifier = Modifier.fillMaxWidth(),
enabled = selectedAreaId != null && selectedCompanyId != null && !opening && !loadingContext,
) {
Text(if (opening) "Abriendo…" else "Abrir inspección ahora")
}
}
}
}
}
if (model.busy && model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
} else if (model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No tenés inspecciones asignadas. Podés abrir una nueva desde esta tablet.")
}
} else {
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(model.visits, key = { it.id }) { visit ->
MobileVisitCard(visit) { model.openVisit(visit.id) }
}
item { Spacer(Modifier.height(24.dp)) }
}
}
}
}
@Composable
private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(visit.code, fontWeight = FontWeight.Bold)
Text(visit.status)
}
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
visit.plannedStartAt?.let {
Text("Prevista: ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall)
}
Text(
"Inventario: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-api",
"version": "0.27.0-1",
"version": "0.29.0-1",
"private": true,
"license": "UNLICENSED",
"scripts": {
@@ -43,4 +43,4 @@
"tsx": "^4.20.6",
"typescript": "^5.9.0"
}
}
}
@@ -26,6 +26,8 @@ import { InventoryStructureController } from './inventory-structure.controller';
import { InventoryStructureService } from './inventory-structure.service';
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller';
import { InventoryTechnicalValuesService } from './inventory-technical-values.service';
import { InventoryFunctionService } from './inventory-function.service';
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
import { InventoryMergeService } from './inventory-merge.service';
@@ -40,6 +42,7 @@ import { InventoryBrowserService } from './inventory-browser.service';
AssetsController,
InventoryStructureController,
InventoryFamilyCatalogController,
InventoryTechnicalValuesController,
InventoryBrowserController,
InventoryMergeController,
FieldInventoryMergeController,
@@ -57,6 +60,7 @@ import { InventoryBrowserService } from './inventory-browser.service';
AssetsService,
InventoryStructureService,
InventoryFamilyCatalogService,
InventoryTechnicalValuesService,
InventoryFunctionService,
InventoryBrowserService,
InventoryMergeService,
@@ -22,8 +22,10 @@ export class CreateInventoryFamilyDto {
name!: string;
@IsOptional()
@IsUUID('4')
parentFamilyId?: string | null;
@IsArray()
@ArrayMaxSize(200)
@IsUUID('4', { each: true })
parentFamilyIds?: string[];
@IsOptional()
@IsArray()
@@ -42,8 +44,10 @@ export class UpdateInventoryFamilyDto {
name?: string;
@IsOptional()
@IsUUID('4')
parentFamilyId?: string | null;
@IsArray()
@ArrayMaxSize(200)
@IsUUID('4', { each: true })
parentFamilyIds?: string[];
@IsOptional()
@IsArray()
@@ -0,0 +1,103 @@
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
const DATA_TYPES = ['TEXT','NUMBER','BOOLEAN','DATE','DATETIME','SELECT'] as const;
export type InventoryFamilyAttributeDataType = typeof DATA_TYPES[number];
export class CreateInventoryFamilyAttributeDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value)
@IsString()
@Matches(/^[a-z][a-z0-9_]*$/)
@MaxLength(80)
code!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(160)
name!: string;
@IsIn(DATA_TYPES)
dataType!: InventoryFamilyAttributeDataType;
@IsOptional()
@IsBoolean()
isRequired?: boolean;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@ValidateIf((_object, value) => value !== null && value !== undefined)
@IsString()
@MaxLength(40)
unit?: string | null;
@ValidateIf((object) => object.dataType === 'SELECT')
@IsArray()
@ArrayMaxSize(200)
@IsString({ each: true })
@MaxLength(160, { each: true })
options?: string[];
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(10000)
sortOrder?: number;
}
export class UpdateInventoryFamilyAttributeDto {
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(160)
name?: string;
@IsOptional()
@IsIn(DATA_TYPES)
dataType?: InventoryFamilyAttributeDataType;
@IsOptional()
@IsBoolean()
isRequired?: boolean;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@ValidateIf((_object, value) => value !== null && value !== undefined)
@IsString()
@MaxLength(40)
unit?: string | null;
@IsOptional()
@IsArray()
@ArrayMaxSize(200)
@IsString({ each: true })
@MaxLength(160, { each: true })
options?: string[] | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(10000)
sortOrder?: number;
}
@@ -0,0 +1,6 @@
import { IsObject } from 'class-validator';
export class UpdateInventoryTechnicalValuesDto {
@IsObject()
values!: Record<string, unknown>;
}
@@ -17,6 +17,10 @@ import {
ReplaceInventoryFamilyFindingsDto,
UpdateInventoryFamilyDto,
} from './dto/inventory-family-admin.dto';
import {
CreateInventoryFamilyAttributeDto,
UpdateInventoryFamilyAttributeDto,
} from './dto/inventory-family-attribute.dto';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
@Controller('inventory-families')
@@ -50,6 +54,35 @@ export class InventoryFamilyCatalogController {
return this.families.update(familyId,dto,principal,request);
}
@Get(':familyId/attributes')
@RequirePermissions('asset_types.read')
attributes(@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string) {
return this.families.attributes(familyId);
}
@Post(':familyId/attributes')
@RequirePermissions('asset_types.manage')
createAttribute(
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
@Body() dto:CreateInventoryFamilyAttributeDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.families.createAttribute(familyId,dto,principal,request);
}
@Patch(':familyId/attributes/:attributeId')
@RequirePermissions('asset_types.manage')
updateAttribute(
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
@Param('attributeId',new ParseUUIDPipe({version:'4'})) attributeId:string,
@Body() dto:UpdateInventoryFamilyAttributeDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.families.updateAttribute(familyId,attributeId,dto,principal,request);
}
@Put(':familyId/findings')
@RequirePermissions('finding_catalog.manage')
replaceFindings(
@@ -15,7 +15,13 @@ import type {
ReplaceInventoryFamilyFindingsDto,
UpdateInventoryFamilyDto,
} from './dto/inventory-family-admin.dto';
import type {
CreateInventoryFamilyAttributeDto,
InventoryFamilyAttributeDataType,
UpdateInventoryFamilyAttributeDto,
} from './dto/inventory-family-attribute.dto';
type FamilyParent = { id: string; code: string; name: string };
type FamilyRow = {
id: string;
code: string;
@@ -24,12 +30,24 @@ type FamilyRow = {
informationLabels: string[];
sourceReference: string | null;
isActive: boolean;
parentFamilyId: string | null;
parentFamilyCode: string | null;
parentFamilyName: string | null;
parentFamilyIds: string[];
parentFamilies: FamilyParent[];
assetCount: number;
findingCount: number;
findingItemIds: string[];
technicalAttributeCount: number;
};
type AttributeRow = {
id: string;
inventoryFamilyId: string;
code: string;
name: string;
dataType: InventoryFamilyAttributeDataType;
isRequired: boolean;
isActive: boolean;
unit: string | null;
options: string[] | null;
sortOrder: number;
};
@Injectable()
@@ -40,27 +58,7 @@ export class InventoryFamilyCatalogService {
) {}
async listAdmin() {
const data = await this.dataSource.query(`
SELECT
family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",
family.is_active AS "isActive",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((
SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY item.title,item.id)
FROM finding_catalog_item_inventory_families mapping
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
WHERE mapping.inventory_family_id=family.id
),'[]'::jsonb) AS "findingItemIds"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
ORDER BY family.level,family.is_active DESC,
COALESCE(parent.name,''),family.name,family.code
`) as FamilyRow[];
const data = await this.dataSource.query(this.familySelect(`ORDER BY family.level,family.is_active DESC,family.name,family.code`)) as FamilyRow[];
return { data };
}
@@ -80,27 +78,28 @@ export class InventoryFamilyCatalogService {
return { family, items, count: items.length };
}
async attributes(familyId: string) {
const family = await this.family(familyId, false);
const items = await this.attributeRows(this.dataSource.manager, familyId);
return { family, items, count: items.length };
}
async create(
dto: CreateInventoryFamilyDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const parentId = await this.validateParent(manager,dto.level,dto.parentFamilyId ?? null,null);
const parentIds = await this.validateParents(manager, dto.level, dto.parentFamilyIds ?? [], null);
const code = `CUSTOM-${dto.level === 'INSTALLATION' ? 'I' : 'S'}-${randomUUID().slice(0,8).toUpperCase()}`;
const [inserted] = (await manager.query(`
INSERT INTO inventory_families(
code,name,level,legacy_type_code,information_labels,source_reference,is_active
) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F5',true)
) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F6',true)
RETURNING id
`,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>;
if (!inserted) throw new Error('No se pudo crear la clasificación de Inventario');
if (parentId) {
await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
`,[inserted.id,parentId]);
}
await this.replaceParents(manager, inserted.id, parentIds);
const created = await this.family(inserted.id,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
@@ -108,7 +107,7 @@ export class InventoryFamilyCatalogService {
entityType: 'inventory_family',
entityId: inserted.id,
afterData: created as unknown as Record<string,unknown>,
metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F5' },
metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F6' },
},manager);
return created;
});
@@ -122,10 +121,13 @@ export class InventoryFamilyCatalogService {
) {
return this.dataSource.transaction(async (manager) => {
const before = await this.family(familyId,false,manager,true);
const nextParentId = dto.parentFamilyId === undefined
? before.parentFamilyId
: dto.parentFamilyId;
const parentId = await this.validateParent(manager,before.level,nextParentId ?? null,familyId);
const nextParentIds = dto.parentFamilyIds === undefined
? before.parentFamilyIds
: dto.parentFamilyIds;
const parentIds = await this.validateParents(manager,before.level,nextParentIds,familyId);
if (before.level === 'SUBINSTALLATION' && dto.parentFamilyIds !== undefined) {
await this.assertRemovedCompatibilitiesUnused(manager, familyId, parentIds);
}
await manager.query(`
UPDATE inventory_families SET
name=COALESCE($2::varchar,name),
@@ -139,15 +141,7 @@ export class InventoryFamilyCatalogService {
dto.informationLabels === undefined ? null : JSON.stringify(this.cleanLabels(dto.informationLabels)),
dto.isActive ?? null,
]);
if (before.level==='SUBINSTALLATION') {
await manager.query(`DELETE FROM inventory_family_parent_rules WHERE child_family_id=$1::uuid`,[familyId]);
if (parentId) {
await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
`,[familyId,parentId]);
}
}
if (dto.parentFamilyIds !== undefined) await this.replaceParents(manager, familyId, parentIds);
const after = await this.family(familyId,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
@@ -162,6 +156,85 @@ export class InventoryFamilyCatalogService {
});
}
async createAttribute(
familyId: string,
dto: CreateInventoryFamilyAttributeDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
await this.family(familyId,false,manager,true);
const options = this.attributeOptions(dto.dataType,dto.options);
try {
const [created] = (await manager.query(`
INSERT INTO inventory_family_attribute_definitions(
inventory_family_id,code,name,data_type,is_required,is_active,unit,options,sort_order,created_by,updated_by
) VALUES ($1::uuid,$2,$3,$4::asset_attribute_data_type,$5,true,$6,$7::jsonb,$8,$9::uuid,$9::uuid)
RETURNING id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
`,[familyId,dto.code,dto.name,dto.dataType,dto.isRequired ?? false,dto.unit ?? null,
options === null ? null : JSON.stringify(options),dto.sortOrder ?? 0,principal.userId])) as AttributeRow[];
if (!created) throw new Error('No se pudo crear el campo técnico');
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType:'inventory_family_attribute',entityId:created.id,
afterData:created as unknown as Record<string,unknown>,
metadata:{operation:'INVENTORY_FAMILY_ATTRIBUTE_CREATED',inventoryFamilyId:familyId},
},manager);
return created;
} catch (error) {
if (this.isUniqueViolation(error)) throw new ConflictException({
code:'INVENTORY_FAMILY_ATTRIBUTE_CODE_EXISTS',
message:'Ya existe un campo técnico con ese código en la clasificación',
});
throw error;
}
});
}
async updateAttribute(
familyId: string,
attributeId: string,
dto: UpdateInventoryFamilyAttributeDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
await this.family(familyId,false,manager,true);
const before = await this.attribute(manager,familyId,attributeId,true);
const nextType = dto.dataType ?? before.dataType;
const nextOptions = dto.options === undefined
? this.attributeOptions(nextType,before.options ?? undefined)
: this.attributeOptions(nextType,dto.options ?? undefined);
const [after] = (await manager.query(`
UPDATE inventory_family_attribute_definitions SET
name=COALESCE($3::varchar,name),
data_type=COALESCE($4::asset_attribute_data_type,data_type),
is_required=COALESCE($5::boolean,is_required),
is_active=COALESCE($6::boolean,is_active),
unit=CASE WHEN $7::boolean THEN $8::varchar ELSE unit END,
options=$9::jsonb,
sort_order=COALESCE($10::integer,sort_order),
updated_by=$11::uuid,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid AND inventory_family_id=$2::uuid
RETURNING id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
`,[attributeId,familyId,dto.name ?? null,dto.dataType ?? null,dto.isRequired ?? null,dto.isActive ?? null,
dto.unit !== undefined,dto.unit ?? null,nextOptions === null ? null : JSON.stringify(nextOptions),
dto.sortOrder ?? null,principal.userId])) as AttributeRow[];
if (!after) throw new NotFoundException({code:'INVENTORY_FAMILY_ATTRIBUTE_NOT_FOUND',message:'El campo técnico no existe'});
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType:'inventory_family_attribute',entityId:attributeId,
beforeData:before as unknown as Record<string,unknown>,afterData:after as unknown as Record<string,unknown>,
metadata:{operation:'INVENTORY_FAMILY_ATTRIBUTE_UPDATED',inventoryFamilyId:familyId},
},manager);
return after;
});
}
async replaceFindings(
familyId: string,
dto: ReplaceInventoryFamilyFindingsDto,
@@ -178,12 +251,10 @@ export class InventoryFamilyCatalogService {
JOIN finding_categories category ON category.id=item.category_id
WHERE item.id=ANY($1::uuid[]) AND item.is_active=true AND category.is_active=true
`,[uniqueIds])) as Array<{total:number}>;
if (Number(count?.total ?? 0)!==uniqueIds.length) {
throw new BadRequestException({
code:'INVENTORY_FAMILY_FINDING_INVALID',
message:'Uno o más Hallazgos elegidos no están activos en el catálogo',
});
}
if (Number(count?.total ?? 0)!==uniqueIds.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_FINDING_INVALID',
message:'Uno o más Hallazgos elegidos no están activos en el catálogo',
});
}
const beforeIds=family.findingItemIds;
await manager.query(`DELETE FROM finding_catalog_item_inventory_families WHERE inventory_family_id=$1::uuid`,[familyId]);
@@ -198,11 +269,9 @@ export class InventoryFamilyCatalogService {
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType:'inventory_family_findings',
entityId:familyId,
beforeData:{ itemIds:beforeIds },
afterData:{ itemIds:after.findingItemIds },
metadata:{ operation:'INVENTORY_FAMILY_FINDINGS_REPLACED', reason:dto.reason },
entityType:'inventory_family_findings',entityId:familyId,
beforeData:{itemIds:beforeIds},afterData:{itemIds:after.findingItemIds},
metadata:{operation:'INVENTORY_FAMILY_FINDINGS_REPLACED',reason:dto.reason},
},manager);
return this.findingsWithManager(manager,familyId);
});
@@ -217,77 +286,150 @@ export class InventoryFamilyCatalogService {
FROM finding_catalog_item_inventory_families mapping
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
JOIN finding_categories category ON category.id=item.category_id
WHERE mapping.inventory_family_id=$1::uuid
AND item.is_active=true AND category.is_active=true
WHERE mapping.inventory_family_id=$1::uuid AND item.is_active=true AND category.is_active=true
ORDER BY category.sort_order,item.source_number,item.title
`,[familyId]);
return {family,items,count:items.length};
}
private familySelect(suffix:string) {
return `
SELECT family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",family.is_active AS "isActive",
COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule
JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule
JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY mapping.catalog_item_id)
FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id),'[]'::jsonb) AS "findingItemIds",
(SELECT COUNT(*)::integer FROM inventory_family_attribute_definitions definition
WHERE definition.inventory_family_id=family.id AND definition.is_active=true) AS "technicalAttributeCount"
FROM inventory_families family
${suffix}
`;
}
private async family(
familyId:string,
activeOnly:boolean,
manager:EntityManager=this.dataSource.manager,
lock=false,
):Promise<FamilyRow> {
const rows=(await manager.query(`
SELECT family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",family.is_active AS "isActive",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY mapping.catalog_item_id)
FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id),'[]'::jsonb) AS "findingItemIds"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
const lockClause=lock ? 'FOR UPDATE OF family' : '';
const rows=(await manager.query(this.familySelect(`
WHERE family.id=$1::uuid ${activeOnly ? 'AND family.is_active=true' : ''}
${lock ? 'FOR UPDATE OF family' : ''}
`,[familyId])) as FamilyRow[];
${lockClause}
`),[familyId])) as FamilyRow[];
if (!rows[0]) throw new NotFoundException({
code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe',
});
return rows[0];
}
private async validateParent(
private async validateParents(
manager:EntityManager,
level:'INSTALLATION'|'SUBINSTALLATION',
parentFamilyId:string|null,
requestedIds:string[],
ownId:string|null,
):Promise<string|null> {
):Promise<string[]> {
const ids=[...new Set(requestedIds)];
if (level==='INSTALLATION') {
if (parentFamilyId) throw new BadRequestException({
if (ids.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED',
message:'Una clasificación de Instalación no tiene clasificación padre',
message:'Una clasificación de Instalación no lleva compatibilidades padre',
});
return null;
return [];
}
if (!parentFamilyId) throw new BadRequestException({
if (!ids.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_REQUIRED',
message:'Una Subinstalación debe pertenecer a un tipo de Instalación',
message:'Elegí al menos un tipo de Instalación compatible con esta Subinstalación',
});
if (parentFamilyId===ownId) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser su propio padre',
if (ownId && ids.includes(ownId)) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser compatible consigo misma',
});
const rows=(await manager.query(`
SELECT id FROM inventory_families
WHERE id=$1::uuid AND level='INSTALLATION' AND is_active=true
LIMIT 1
`,[parentFamilyId])) as Array<{id:string}>;
if (!rows[0]) throw new BadRequestException({
const [count]=(await manager.query(`
SELECT COUNT(*)::integer AS total FROM inventory_families
WHERE id=ANY($1::uuid[]) AND level='INSTALLATION' AND is_active=true
`,[ids])) as Array<{total:number}>;
if (Number(count?.total ?? 0)!==ids.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_INVALID',
message:'La Subinstalación debe vincularse a una clasificación de Instalación activa',
message:'Todas las compatibilidades deben ser clasificaciones de Instalación activas',
});
return parentFamilyId;
return ids;
}
private async replaceParents(manager:EntityManager,familyId:string,parentIds:string[]) {
await manager.query(`DELETE FROM inventory_family_parent_rules WHERE child_family_id=$1::uuid`,[familyId]);
if (parentIds.length) await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
SELECT $1::uuid,parent_id FROM UNNEST($2::uuid[]) AS selected(parent_id)
ON CONFLICT (child_family_id,parent_family_id) DO NOTHING
`,[familyId,parentIds]);
}
private async assertRemovedCompatibilitiesUnused(manager:EntityManager,childFamilyId:string,nextParentIds:string[]) {
const rows=await manager.query(`
SELECT child.name AS "childName",parent.name AS "parentName",COUNT(*)::integer AS total
FROM assets child
JOIN assets parent ON parent.id=child.parent_id
WHERE child.inventory_family_id=$1::uuid
AND parent.inventory_family_id IS NOT NULL
AND NOT (parent.inventory_family_id=ANY($2::uuid[]))
GROUP BY child.name,parent.name
ORDER BY total DESC
LIMIT 1
`,[childFamilyId,nextParentIds]);
if (rows[0]) throw new ConflictException({
code:'INVENTORY_FAMILY_COMPATIBILITY_IN_USE',
message:`No podés quitar esa compatibilidad: ya existen Subinstalaciones de este tipo dentro de ${rows[0].parentName}`,
});
}
private async attributeRows(manager:EntityManager,familyId:string):Promise<AttributeRow[]> {
return manager.query(`
SELECT id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
FROM inventory_family_attribute_definitions
WHERE inventory_family_id=$1::uuid
ORDER BY is_active DESC,sort_order,name,code
`,[familyId]) as Promise<AttributeRow[]>;
}
private async attribute(manager:EntityManager,familyId:string,attributeId:string,lock=false):Promise<AttributeRow> {
const rows=(await manager.query(`
SELECT id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
FROM inventory_family_attribute_definitions
WHERE id=$1::uuid AND inventory_family_id=$2::uuid
${lock ? 'FOR UPDATE' : ''}
`,[attributeId,familyId])) as AttributeRow[];
if (!rows[0]) throw new NotFoundException({code:'INVENTORY_FAMILY_ATTRIBUTE_NOT_FOUND',message:'El campo técnico no existe'});
return rows[0];
}
private attributeOptions(type:InventoryFamilyAttributeDataType,raw:string[]|undefined):string[]|null {
if (type!=='SELECT') return null;
const values=[...new Map((raw ?? []).map((value) => {
const clean=value.trim(); return [clean.toLocaleLowerCase('es-AR'),clean] as const;
}).filter(([,value]) => Boolean(value))).values()];
if (!values.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_ATTRIBUTE_OPTIONS_REQUIRED',
message:'Un campo de lista necesita al menos una opción',
});
return values;
}
private cleanLabels(labels:string[]):string[] {
const unique=new Map<string,string>();
for (const raw of labels) {
const clean=raw.trim();
if (!clean) continue;
const clean=raw.trim(); if (!clean) continue;
const identity=clean.toLocaleLowerCase('es-AR');
if (!unique.has(identity)) unique.set(identity,clean);
}
@@ -296,4 +438,8 @@ export class InventoryFamilyCatalogService {
});
return [...unique.values()];
}
private isUniqueViolation(error:unknown) {
return Boolean(error && typeof error==='object' && 'code' in error && (error as {code?:string}).code==='23505');
}
}
@@ -20,6 +20,7 @@ import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/
import { AssetHistoryService } from './asset-history.service';
type StructureTypeRow = { id: string; code: string; name: string };
type FamilyParent = { id: string; code: string; name: string };
type FamilyRow = {
id: string;
code: string;
@@ -27,9 +28,8 @@ type FamilyRow = {
level: 'INSTALLATION' | 'SUBINSTALLATION';
legacyTypeCode: string | null;
informationLabels: string[];
parentFamilyId: string | null;
parentFamilyCode: string | null;
parentFamilyName: string | null;
parentFamilyIds: string[];
parentFamilies: FamilyParent[];
};
type ParentRow = {
id: string;
@@ -101,10 +101,15 @@ export class InventoryStructureService {
SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode",
family.information_labels AS "informationLabels",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName"
COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule
JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule
JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.is_active=true
ORDER BY family.level,family.name,family.code
`)) as FamilyRow[];
@@ -201,7 +206,7 @@ export class InventoryStructureService {
AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL,
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas F5.1' : 'Estructura manual de Inventario F5.1',
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas F6' : 'Estructura manual de Inventario F6',
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
family ? `Clasificación técnica: ${family.code} · ${family.name}` : null,
principal.userId,
@@ -218,11 +223,7 @@ export class InventoryStructureService {
}
const versionNumber = await this.history.capture(
manager,
id,
AssetVersionChangeType.CREATED,
principal,
request,
manager,id,AssetVersionChangeType.CREATED,principal,request,
);
await manager.query(`
INSERT INTO asset_context_history (
@@ -230,38 +231,28 @@ export class InventoryStructureService {
change_reason,asset_version_number,source,request_id,created_by
) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7)
`, [
id,
parent?.id ?? null,
operationalAreaId,
dto.kind === 'EMPRESA' ? 'Alta manual de Empresa independiente F5.1' : 'Alta manual de estructura de Inventario F5.1',
versionNumber,
request.requestId,
principal.userId,
id,parent?.id ?? null,operationalAreaId,
dto.kind === 'EMPRESA' ? 'Alta manual de Empresa independiente F6' : 'Alta manual de estructura de Inventario F6',
versionNumber,request.requestId,principal.userId,
]);
const created = await this.loadView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_CREATED,
entityType: 'asset',
entityId: id,
entityType: 'asset',entityId: id,
afterData: created as unknown as Record<string, unknown>,
metadata: {
versionNumber,
inventoryStructureKind: dto.kind,
inventoryFamilyId: family?.id ?? null,
inventoryFamilyCode: family?.code ?? null,
versionNumber,inventoryStructureKind: dto.kind,
inventoryFamilyId: family?.id ?? null,inventoryFamilyCode: family?.code ?? null,
operatorOwnership: false,
},
}, manager);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new ConflictException({
code: 'ASSET_CODE_ALREADY_EXISTS',
message: 'Ya existe un registro con ese código',
});
}
if (isUniqueViolation(error)) throw new ConflictException({
code: 'ASSET_CODE_ALREADY_EXISTS',message: 'Ya existe un registro con ese código',
});
throw error;
}
}
@@ -272,12 +263,9 @@ export class InventoryStructureService {
: `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1`;
const params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude<InventoryStructureKind,'EMPRESA'>]];
const rows = (await manager.query(sql,params)) as StructureTypeRow[];
if (!rows[0]) {
throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',
message: `El nivel ${kind} no está configurado`,
});
}
if (!rows[0]) throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',message: `El nivel ${kind} no está configurado`,
});
return rows[0];
}
@@ -288,22 +276,18 @@ export class InventoryStructureService {
): Promise<ParentRow | null> {
const expectedType = PARENT_TYPE_BY_KIND[kind];
if (!expectedType) {
if (parentId) {
throw new BadRequestException({
code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT',
message: kind === 'EMPRESA'
? 'Una Empresa es un maestro independiente y no puede tener padre'
: 'Un Departamento es un registro raíz y no puede tener padre',
});
}
if (parentId) throw new BadRequestException({
code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT',
message: kind === 'EMPRESA'
? 'Una Empresa es un maestro independiente y no puede tener padre'
: 'Un Departamento es un registro raíz y no puede tener padre',
});
return null;
}
if (!parentId) {
throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED',
message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`,
});
}
if (!parentId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED',
message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`,
});
const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
asset.inventory_family_id AS "inventoryFamilyId"
@@ -314,12 +298,10 @@ export class InventoryStructureService {
`, [parentId])) as ParentRow[];
const parent = rows[0];
if (!parent) throw new NotFoundException({ code: 'INVENTORY_STRUCTURE_PARENT_NOT_FOUND', message: 'El registro padre no existe' });
if (parent.typeCode.toLowerCase() !== expectedType) {
throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_INVALID',
message: 'La jerarquía requerida es Departamento → Área → Yacimiento → Instalación → Subinstalación',
});
}
if (parent.typeCode.toLowerCase() !== expectedType) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_INVALID',
message: 'La jerarquía requerida es Departamento → Área → Yacimiento → Instalación → Subinstalación',
});
return parent;
}
@@ -338,12 +320,10 @@ export class InventoryStructureService {
LIMIT 1
`,[parent.id])) as IdRow[];
const areaId=rows[0]?.id;
if (!areaId) {
throw new ConflictException({
code:'INVENTORY_STRUCTURE_AREA_ANCESTOR_MISSING',
message:'La ubicación seleccionada no pertenece a un Área válida',
});
}
if (!areaId) throw new ConflictException({
code:'INVENTORY_STRUCTURE_AREA_ANCESTOR_MISSING',
message:'La ubicación seleccionada no pertenece a un Área válida',
});
return areaId;
}
@@ -367,12 +347,13 @@ export class InventoryStructureService {
});
const rows = (await manager.query(`
SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode",
family.information_labels AS "informationLabels",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName"
family.legacy_type_code AS "legacyTypeCode",family.information_labels AS "informationLabels",
COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY rule.parent_family_id)
FROM inventory_family_parent_rules rule WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.id=$1::uuid AND family.is_active=true
LIMIT 1
`, [familyId])) as FamilyRow[];
@@ -382,10 +363,19 @@ export class InventoryStructureService {
code: 'INVENTORY_FAMILY_LEVEL_INVALID',
message: 'La clasificación técnica no corresponde al nivel seleccionado',
});
if (kind === 'SUBINSTALACION' && family.parentFamilyId !== parent?.inventoryFamilyId) {
throw new BadRequestException({
if (kind === 'SUBINSTALACION') {
if (!parent?.inventoryFamilyId) throw new BadRequestException({
code:'INVENTORY_PARENT_FAMILY_REQUIRED',
message:'La Instalación padre debe tener una clasificación técnica válida',
});
const [compatible]=(await manager.query(`
SELECT 1 AS ok FROM inventory_family_parent_rules
WHERE child_family_id=$1::uuid AND parent_family_id=$2::uuid
LIMIT 1
`,[family.id,parent.inventoryFamilyId])) as Array<{ok:number}>;
if (!compatible) throw new BadRequestException({
code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID',
message: 'La Subinstalación elegida no pertenece a la clasificación de la Instalación seleccionada',
message: 'Ese tipo de Subinstalación no es compatible con la clasificación de la Instalación seleccionada',
});
}
return family;
@@ -398,13 +388,8 @@ export class InventoryStructureService {
: kind === 'YACIMIENTO' ? 'YAC'
: kind === 'INSTALACION' ? 'INST'
: 'SUB';
const readable = name
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48) || 'REGISTRO';
const readable = name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase()
.replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'REGISTRO';
return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120);
}
@@ -0,0 +1,28 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Put, Req } from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { UpdateInventoryTechnicalValuesDto } from './dto/update-inventory-technical-values.dto';
import { InventoryTechnicalValuesService } from './inventory-technical-values.service';
@Controller('assets/:assetId/technical-values')
export class InventoryTechnicalValuesController {
constructor(private readonly technical:InventoryTechnicalValuesService) {}
@Get()
@RequirePermissions('assets.read')
get(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string) {
return this.technical.get(assetId);
}
@Put()
@RequirePermissions('assets.update')
replace(
@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string,
@Body() dto:UpdateInventoryTechnicalValuesDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.technical.replace(assetId,dto,principal,request);
}
}
@@ -0,0 +1,118 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction } from '../database/entities';
import type { UpdateInventoryTechnicalValuesDto } from './dto/update-inventory-technical-values.dto';
type DefinitionRow = {
id:string; code:string; name:string; dataType:'TEXT'|'NUMBER'|'BOOLEAN'|'DATE'|'DATETIME'|'SELECT';
isRequired:boolean; isActive:boolean; unit:string|null; options:string[]|null; sortOrder:number;
};
type AssetRow = { id:string; inventoryFamilyId:string|null; familyCode:string|null; familyName:string|null; familyLevel:string|null };
@Injectable()
export class InventoryTechnicalValuesService {
constructor(private readonly dataSource:DataSource,private readonly audit:AuditService) {}
async get(assetId:string) {
return this.load(this.dataSource.manager,assetId);
}
async replace(assetId:string,dto:UpdateInventoryTechnicalValuesDto,principal:AuthPrincipal,request:RequestWithContext) {
return this.dataSource.transaction(async(manager) => {
const before=await this.load(manager,assetId,true);
const definitions=before.definitions as DefinitionRow[];
const definitionById=new Map(definitions.map((definition)=>[definition.id,definition]));
const normalized:Record<string,unknown>={};
for (const [definitionId,raw] of Object.entries(dto.values)) {
const definition=definitionById.get(definitionId);
if (!definition || !definition.isActive) throw new BadRequestException({
code:'INVENTORY_TECHNICAL_FIELD_INVALID',message:'Uno o más campos técnicos no pertenecen a la clasificación actual',
});
const value=this.normalize(definition,raw);
if (value!==undefined) normalized[definitionId]=value;
}
for (const definition of definitions.filter((item)=>item.isActive && item.isRequired)) {
if (!(definition.id in normalized)) throw new BadRequestException({
code:'INVENTORY_TECHNICAL_FIELD_REQUIRED',message:`Completá el campo técnico obligatorio: ${definition.name}`,
});
}
await manager.query('DELETE FROM asset_inventory_attribute_values WHERE asset_id=$1::uuid',[assetId]);
const entries=Object.entries(normalized);
if (entries.length) await manager.query(`
INSERT INTO asset_inventory_attribute_values(asset_id,definition_id,value,updated_by)
SELECT $1::uuid,item.definition_id,item.value,$3::uuid
FROM JSONB_TO_RECORDSET($2::jsonb) AS item(definition_id uuid,value jsonb)
`,[assetId,JSON.stringify(entries.map(([definition_id,value])=>({definition_id,value}))),principal.userId]);
const after=await this.load(manager,assetId);
await this.audit.record({
...administrationAuditContext(principal,request),action:AuditAction.ASSET_UPDATED,
entityType:'asset_inventory_technical_values',entityId:assetId,
beforeData:{values:before.values},afterData:{values:after.values},
metadata:{operation:'INVENTORY_TECHNICAL_VALUES_REPLACED',inventoryFamilyId:after.family.id},
},manager);
return after;
});
}
private async load(manager:EntityManager,assetId:string,lock=false) {
const rows=(await manager.query(`
SELECT asset.id,asset.inventory_family_id AS "inventoryFamilyId",
family.code AS "familyCode",family.name AS "familyName",family.level::text AS "familyLevel"
FROM assets asset LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid ${lock ? 'FOR UPDATE OF asset' : ''}
`,[assetId])) as AssetRow[];
const asset=rows[0];
if (!asset) throw new NotFoundException({code:'ASSET_NOT_FOUND',message:'El registro de Inventario no existe'});
if (!asset.inventoryFamilyId || !asset.familyCode || !asset.familyName || !asset.familyLevel) throw new BadRequestException({
code:'INVENTORY_TECHNICAL_FAMILY_REQUIRED',message:'Este nivel no tiene clasificación técnica y no admite campos técnicos por rubro',
});
const definitions=(await manager.query(`
SELECT id,code,name,data_type AS "dataType",is_required AS "isRequired",is_active AS "isActive",
unit,options,sort_order AS "sortOrder"
FROM inventory_family_attribute_definitions
WHERE inventory_family_id=$1::uuid
ORDER BY is_active DESC,sort_order,name,code
`,[asset.inventoryFamilyId])) as DefinitionRow[];
const valueRows=await manager.query(`
SELECT definition_id AS id,value FROM asset_inventory_attribute_values WHERE asset_id=$1::uuid
`,[assetId]) as Array<{id:string;value:unknown}>;
return {
assetId,
family:{id:asset.inventoryFamilyId,code:asset.familyCode,name:asset.familyName,level:asset.familyLevel},
definitions,
values:Object.fromEntries(valueRows.map((row)=>[row.id,row.value])),
};
}
private normalize(definition:DefinitionRow,raw:unknown):unknown|undefined {
if (raw===null || raw===undefined || raw==='') return undefined;
switch(definition.dataType) {
case 'TEXT': {
if (typeof raw!=='string') return this.invalid(definition);
const value=raw.trim(); if (!value) return undefined; if (value.length>4000) return this.invalid(definition); return value;
}
case 'NUMBER': {
const value=typeof raw==='number' ? raw : typeof raw==='string' ? Number(raw) : Number.NaN;
if (!Number.isFinite(value)) return this.invalid(definition); return value;
}
case 'BOOLEAN': if (typeof raw!=='boolean') return this.invalid(definition); return raw;
case 'DATE': {
if (typeof raw!=='string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw) || Number.isNaN(Date.parse(`${raw}T00:00:00Z`))) return this.invalid(definition);
return raw;
}
case 'DATETIME': {
if (typeof raw!=='string' || Number.isNaN(Date.parse(raw))) return this.invalid(definition); return new Date(raw).toISOString();
}
case 'SELECT': {
if (typeof raw!=='string' || !definition.options?.includes(raw)) return this.invalid(definition); return raw;
}
}
}
private invalid(definition:DefinitionRow):never {
throw new BadRequestException({code:'INVENTORY_TECHNICAL_VALUE_INVALID',message:`Valor inválido para ${definition.name}`});
}
}
@@ -0,0 +1,226 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F6SolidInventoryModel1790091000000 implements MigrationInterface {
name = 'F6SolidInventoryModel1790091000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// A technical Subinstallation family may be valid under several Installation
// families. F3.1 used child_family_id as the PK, which made this relation
// accidentally one-to-one from the child's point of view.
await queryRunner.query(`
DO $$
DECLARE constraint_name text;
BEGIN
SELECT con.conname INTO constraint_name
FROM pg_constraint con
JOIN pg_class rel ON rel.oid=con.conrelid
WHERE rel.relname='inventory_family_parent_rules' AND con.contype='p'
LIMIT 1;
IF constraint_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE inventory_family_parent_rules DROP CONSTRAINT %I',constraint_name);
END IF;
END $$;
`);
await queryRunner.query(`
ALTER TABLE inventory_family_parent_rules
ADD CONSTRAINT pk_inventory_family_parent_rules
PRIMARY KEY (child_family_id,parent_family_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_inventory_family_parent_rules_parent
ON inventory_family_parent_rules(parent_family_id,child_family_id)
`);
// Protect compatibility semantics at database level.
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_inventory_family_compatibility_rule()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE child_level text; parent_level text;
BEGIN
SELECT level::text INTO child_level FROM inventory_families WHERE id=NEW.child_family_id;
SELECT level::text INTO parent_level FROM inventory_families WHERE id=NEW.parent_family_id;
IF child_level IS DISTINCT FROM 'SUBINSTALLATION' OR parent_level IS DISTINCT FROM 'INSTALLATION' THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La compatibilidad debe vincular Subinstalación con Instalación';
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query('DROP TRIGGER IF EXISTS trg_inventory_family_compatibility_rule ON inventory_family_parent_rules');
await queryRunner.query(`
CREATE TRIGGER trg_inventory_family_compatibility_rule
BEFORE INSERT OR UPDATE OF child_family_id,parent_family_id ON inventory_family_parent_rules
FOR EACH ROW EXECUTE FUNCTION enforce_inventory_family_compatibility_rule()
`);
// Family-specific technical fields. Generic asset type attributes remain for
// data genuinely shared by a whole structural level.
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS inventory_family_attribute_definitions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
inventory_family_id uuid NOT NULL REFERENCES inventory_families(id) ON DELETE CASCADE,
code varchar(80) NOT NULL,
name varchar(160) NOT NULL,
data_type asset_attribute_data_type NOT NULL,
is_required boolean NOT NULL DEFAULT false,
is_active boolean NOT NULL DEFAULT true,
unit varchar(40) NULL,
options jsonb NULL,
sort_order integer NOT NULL DEFAULT 0,
created_by uuid NULL,
updated_by uuid NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_inventory_family_attribute_code UNIQUE(inventory_family_id,code),
CONSTRAINT chk_inventory_family_attribute_code CHECK (code ~ '^[a-z][a-z0-9_]*$'),
CONSTRAINT chk_inventory_family_attribute_options CHECK (
(data_type='SELECT' AND options IS NOT NULL AND jsonb_typeof(options)='array')
OR (data_type<>'SELECT')
)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_inventory_family_attributes_family
ON inventory_family_attribute_definitions(inventory_family_id,is_active,sort_order,name)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS asset_inventory_attribute_values (
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
definition_id uuid NOT NULL REFERENCES inventory_family_attribute_definitions(id) ON DELETE CASCADE,
value jsonb NOT NULL,
updated_by uuid NULL,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(asset_id,definition_id)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_asset_inventory_attribute_values_definition
ON asset_inventory_attribute_values(definition_id,asset_id)
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_asset_inventory_attribute_family()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE asset_family uuid; definition_family uuid;
BEGIN
SELECT inventory_family_id INTO asset_family FROM assets WHERE id=NEW.asset_id;
SELECT inventory_family_id INTO definition_family
FROM inventory_family_attribute_definitions WHERE id=NEW.definition_id AND is_active=true;
IF asset_family IS NULL OR definition_family IS NULL OR asset_family<>definition_family THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El campo técnico no pertenece a la clasificación del Inventario';
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query('DROP TRIGGER IF EXISTS trg_asset_inventory_attribute_family ON asset_inventory_attribute_values');
await queryRunner.query(`
CREATE TRIGGER trg_asset_inventory_attribute_family
BEFORE INSERT OR UPDATE OF asset_id,definition_id ON asset_inventory_attribute_values
FOR EACH ROW EXECUTE FUNCTION enforce_asset_inventory_attribute_family()
`);
// Enforce classification level and Subinstallation compatibility on every
// physical Inventory write, not just through the current WEB/API.
// A FIELD_SURVEY+DRAFT row may exist momentarily without family because the
// field-discovery transaction creates the provisional record before the F6
// structure service assigns the family selected by the inspector. Such a row
// remains unusable for findings until classification is present.
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_f6_asset_family_contract()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE child_type text; family_level text; parent_family uuid;
BEGIN
SELECT lower(code) INTO child_type FROM asset_types WHERE id=NEW.asset_type_id;
IF child_type IN ('departamento','area','yacimiento') THEN
IF NEW.inventory_family_id IS NOT NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento, Área y Yacimiento no llevan clasificación técnica';
END IF;
RETURN NEW;
END IF;
IF child_type='instalacion' THEN
IF NEW.inventory_family_id IS NULL THEN
IF NEW.data_origin='FIELD_SURVEY' AND NEW.information_status='DRAFT' THEN
RETURN NEW;
END IF;
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Instalación requiere clasificación técnica';
END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'INSTALLATION' THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La clasificación elegida no corresponde a una Instalación';
END IF;
RETURN NEW;
END IF;
IF child_type='subinstalacion' THEN
IF NEW.parent_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Subinstalación requiere una Instalación padre';
END IF;
IF NEW.inventory_family_id IS NULL THEN
IF NEW.data_origin='FIELD_SURVEY' AND NEW.information_status='DRAFT' THEN
RETURN NEW;
END IF;
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Subinstalación requiere clasificación técnica';
END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
SELECT inventory_family_id INTO parent_family FROM assets WHERE id=NEW.parent_id;
IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La clasificación elegida no corresponde a una Subinstalación';
END IF;
IF parent_family IS NULL OR NOT EXISTS (
SELECT 1 FROM inventory_family_parent_rules rule
WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family
) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La clasificación de Subinstalación no es compatible con la Instalación padre';
END IF;
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f6_asset_family_contract ON assets');
await queryRunner.query(`
CREATE TRIGGER trg_f6_asset_family_contract
BEFORE INSERT OR UPDATE OF asset_type_id,parent_id,inventory_family_id ON assets
FOR EACH ROW EXECUTE FUNCTION enforce_f6_asset_family_contract()
`);
const [check] = (await queryRunner.query(`
SELECT
(SELECT COUNT(*)::integer FROM pg_constraint c JOIN pg_class r ON r.oid=c.conrelid
WHERE r.relname='inventory_family_parent_rules' AND c.contype='p'
AND pg_get_constraintdef(c.oid) LIKE '%child_family_id, parent_family_id%') AS composite_pk,
to_regclass('inventory_family_attribute_definitions') IS NOT NULL AS family_attributes,
to_regclass('asset_inventory_attribute_values') IS NOT NULL AS family_values
`)) as Array<{ composite_pk:number; family_attributes:boolean; family_values:boolean }>;
if (!check || Number(check.composite_pk)!==1 || !check.family_attributes || !check.family_values) {
throw new Error(`F6 inventory model verification failed: ${JSON.stringify(check ?? {})}`);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f6_asset_family_contract ON assets');
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f6_asset_family_contract()');
await queryRunner.query('DROP TRIGGER IF EXISTS trg_asset_inventory_attribute_family ON asset_inventory_attribute_values');
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_asset_inventory_attribute_family()');
await queryRunner.query('DROP TABLE IF EXISTS asset_inventory_attribute_values');
await queryRunner.query('DROP TABLE IF EXISTS inventory_family_attribute_definitions');
await queryRunner.query('DROP TRIGGER IF EXISTS trg_inventory_family_compatibility_rule ON inventory_family_parent_rules');
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_inventory_family_compatibility_rule()');
await queryRunner.query('DROP INDEX IF EXISTS idx_inventory_family_parent_rules_parent');
// Old schema allowed only one Installation family per Subinstallation family.
// Keep a deterministic first relation if F6 data must be rolled back.
await queryRunner.query(`
DELETE FROM inventory_family_parent_rules rule
USING inventory_family_parent_rules keep
WHERE rule.child_family_id=keep.child_family_id
AND rule.parent_family_id>keep.parent_family_id
`);
await queryRunner.query('ALTER TABLE inventory_family_parent_rules DROP CONSTRAINT IF EXISTS pk_inventory_family_parent_rules');
await queryRunner.query(`
ALTER TABLE inventory_family_parent_rules
ADD CONSTRAINT inventory_family_parent_rules_pkey PRIMARY KEY(child_family_id)
`);
}
}
@@ -1,10 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
import { FindingCatalogService } from './finding-catalog.service';
@Injectable()
export class F3FindingCatalogResolverService {
constructor(private readonly dataSource: DataSource) {}
constructor(
private readonly dataSource: DataSource,
private readonly contextualCatalog: FindingCatalogService,
) {}
async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) {
const [asset] = await this.dataSource.query(`
@@ -36,19 +40,28 @@ export class F3FindingCatalogResolverService {
enabled: true,
code: 'OTHER',
label: 'OTROS',
help: 'Usalo cuando el Hallazgo no exista en el catálogo aplicable. Se enviará una propuesta a revisión de oficina.',
help: 'Usalo cuando el Hallazgo no exista entre los asociados a este Inventario. Quedará registrado para revisión en oficina.',
};
// F5: no se mezclan catálogos históricos. Yacimiento no posee familia técnica
// en final_modelov2.xlsx, por lo que puede registrar Hallazgos mediante OTROS.
if (asset.typeCode.toLowerCase() === 'yacimiento') {
const contextual = await this.contextualCatalog.listApplicableForAsset(assetId, query);
return {
...contextual,
inventoryFamily: null,
catalogSource: 'ASSET_TYPE' as const,
configurationReason: contextual.configurationReason
?? 'Hallazgos asociados al tipo Yacimiento. OTROS permanece siempre disponible.',
other,
};
}
if (!asset.familyId || asset.familyActive !== true) {
return {
asset: { id: asset.id, code: asset.code, name: asset.name },
inventoryFamily: null,
catalogSource: 'INVENTORY_FAMILY' as const,
typeConfigured: false,
configurationReason: asset.typeCode.toLowerCase() === 'yacimiento'
? 'F5 · Yacimiento sin catálogo precargado: Hallazgos disponibles mediante OTROS.'
: 'F5 · El elemento todavía no tiene una clasificación técnica activa.',
configurationReason: 'El elemento todavía no tiene una clasificación técnica activa.',
categories: [],
items: [],
other,
@@ -59,7 +72,6 @@ export class F3FindingCatalogResolverService {
'mapping.inventory_family_id=$1::uuid',
'item.is_active=true',
'category.is_active=true',
"lower(category.code)='f5model'",
'merge_record.source_item_id IS NULL',
];
const params: unknown[] = [asset.familyId];
@@ -106,8 +118,9 @@ export class F3FindingCatalogResolverService {
name: asset.familyName,
level: asset.familyLevel,
},
catalogSource: 'INVENTORY_FAMILY' as const,
typeConfigured: true,
configurationReason: `F5 · Catálogo final_modelov2.xlsx asociado a ${asset.familyName ?? 'la clasificación técnica'}`,
configurationReason: `Hallazgos asociados a la clasificación técnica ${asset.familyName ?? asset.familyCode ?? ''}`.trim(),
categories,
items,
other,
@@ -0,0 +1,9 @@
import { IsUUID } from 'class-validator';
export class OpenMobileInspectionDto {
@IsUUID('4')
operationalAreaId!: string;
@IsUUID('4')
operatorCompanyId!: string;
}
@@ -138,8 +138,31 @@ export class FieldFindingsService {
asset.id AS "assetId",
asset_type.code AS "assetTypeCode",
asset.inventory_family_id AS "inventoryFamilyId",
asset.operational_area_id AS "assetAreaId",
asset.operator_company_id AS "assetCompanyId",
(
asset.operational_area_id=visit.operational_area_id
OR asset.id=visit.operational_area_id
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
)
SELECT 1 FROM ancestors WHERE id=visit.operational_area_id LIMIT 1
)
) AS "insideArea",
EXISTS (
SELECT 1
FROM area_company_relations relation
WHERE relation.area_id=visit.operational_area_id
AND relation.company_id=visit.operator_company_id
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
AND (
relation.valid_until IS NULL
OR relation.valid_until >= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
)
) AS "operatorRelationValid",
EXISTS (
SELECT 1
FROM inspection_visit_members member
@@ -199,8 +222,8 @@ export class FieldFindingsService {
assetId: string | null;
assetTypeCode: string | null;
inventoryFamilyId: string | null;
assetAreaId: string | null;
assetCompanyId: string | null;
insideArea: boolean;
operatorRelationValid: boolean;
assigned: boolean;
selected: boolean;
captureRequired: boolean;
@@ -230,28 +253,35 @@ export class FieldFindingsService {
message: 'La inspección no tiene Área y Operadora definidas',
});
}
if (!row.operatorRelationValid) {
throw new ConflictException({
code: 'FIELD_FINDING_OPERATOR_RELATION_INVALID',
message: 'La Operadora seleccionada no estaba vinculada al Área para la fecha de esta inspección',
});
}
if (!row.assetId) {
throw new NotFoundException({
code: 'FIELD_FINDING_INVENTORY_NOT_FOUND',
message: 'Registro de Inventario no encontrado',
});
}
if (!['instalacion', 'subinstalacion'].includes(String(row.assetTypeCode).toLowerCase())) {
const assetTypeCode = String(row.assetTypeCode).toLowerCase();
if (!['yacimiento', 'instalacion', 'subinstalacion'].includes(assetTypeCode)) {
throw new BadRequestException({
code: 'FIELD_FINDING_TECHNICAL_LEVEL_REQUIRED',
message: 'Los Hallazgos se registran sobre una Instalación o Subinstalación, no sobre Área/Yacimiento',
code: 'FIELD_FINDING_TARGET_LEVEL_INVALID',
message: 'Los Hallazgos pueden registrarse sobre Yacimiento, Instalación o Subinstalación',
});
}
if (!row.inventoryFamilyId) {
if (assetTypeCode !== 'yacimiento' && !row.inventoryFamilyId) {
throw new ConflictException({
code: 'FIELD_FINDING_FAMILY_REQUIRED',
message: 'La Instalación/Subinstalación debe tener una familia técnica u Otro antes de registrar Hallazgos',
message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos',
});
}
if (row.assetAreaId !== row.areaId || row.assetCompanyId !== row.companyId) {
if (!row.insideArea) {
throw new BadRequestException({
code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT',
message: 'El Inventario no pertenece al Área y Operadora de esta inspección',
message: 'El Inventario no pertenece al Área de esta inspección',
});
}
if (!row.selected) {
@@ -13,11 +13,13 @@ import {
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import { CancelInspectionVisitDto } from './dto/cancel-inspection-visit.dto';
import { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto';
import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto';
import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
import { OpenMobileInspectionDto } from './dto/open-mobile-inspection.dto';
import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto';
import { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
@@ -57,6 +59,41 @@ export class InspectionVisitsController {
return this.visits.listPlanningOperators(areaId);
}
@Get('mobile/planning-context/areas')
@RequirePermissions('inspections.execute')
mobilePlanningAreas(@CurrentAuth() principal: AuthPrincipal) {
assertMobileInspector(principal);
return this.visits.listPlanningAreas();
}
@Get('mobile/planning-context/areas/:areaId/operators')
@RequirePermissions('inspections.execute')
mobilePlanningOperators(
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
@CurrentAuth() principal: AuthPrincipal,
) {
assertMobileInspector(principal);
return this.visits.listPlanningOperators(areaId);
}
@Post('mobile/open')
@RequirePermissions('inspections.execute')
async openMobile(
@Body() dto: OpenMobileInspectionDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
assertMobileInspector(principal);
const created = await this.visits.create({
operationalAreaId: dto.operationalAreaId,
operatorCompanyId: dto.operatorCompanyId,
plannedStartAt: new Date().toISOString(),
leadInspectorUserId: principal.userId,
}, principal, request);
await this.lifecycle.plan(created.id, principal, request);
return this.lifecycle.start(created.id, principal, request);
}
@Get(':id')
@RequirePermissions('inspections.read')
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.26.0-1';
export const API_PHASE = 'F5';
export const API_VERSION = '0.29.0-1';
export const API_PHASE = 'F6.1';
+4 -4
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { API_PHASE, API_VERSION } from '../../src/version';
test('health metadata reports the current F5 release', () => {
assert.equal(API_PHASE, 'F5');
assert.equal(API_VERSION, '0.26.0-1');
});
test('health metadata reports the current F6.1 release', () => {
assert.equal(API_PHASE, 'F6.1');
assert.equal(API_VERSION, '0.29.0-1');
});
@@ -64,14 +64,14 @@ test('F5.1 Finding Catalog defaults to associated findings and exposes all items
assert.match(panel, /Esta clasificación todavía no tiene Hallazgos asociados/);
});
test('F5.1 Configuration manages findings inline and filters Subinstallations by Installation', () => {
test('F5.1/F6 Configuration manages findings inline and filters Subinstallations by compatible Installation classifications', () => {
const configPage = source('../web-v2/src/pages/AssetTypesPage.tsx');
assert.match(configPage, /replaceInventoryFamilyFindings/);
assert.match(configPage, /Agregar o quitar/);
assert.match(configPage, /familyFindingIds/);
assert.match(configPage, /selectedInstallationFamilyId/);
assert.match(configPage, /family\.parentFamilyId === selectedInstallationFamilyId/);
assert.match(configPage, /family\.parentFamilyIds\.includes\(selectedInstallationFamilyId\)/);
assert.match(configPage, /Filtrar por tipo de Instalación/);
});
+5 -5
View File
@@ -7,16 +7,16 @@ function mountedRepoFile(path: string): string {
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
}
test('F5 Android test cut targets production API and has a distinct installable debug version', () => {
test('F6.1 Android test cut targets production API and has a distinct installable debug version', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 20/);
assert.match(gradle, /versionName = "0\.13\.0"/);
assert.match(gradle, /versionCode = 22/);
assert.match(gradle, /versionName = "0\.15\.0"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
});
test('F5 field inventory exposes Other families as reviewable choices to Android', () => {
test('F5/F6.1 field inventory exposes Other families as reviewable choices to Android', () => {
const service = readFileSync(
resolve(process.cwd(), 'src/inspection-visits/f3-field-inventory-structure.service.ts'),
'utf8',
@@ -25,4 +25,4 @@ test('F5 field inventory exposes Other families as reviewable choices to Android
assert.match(service, /F5:SYSTEM:OTHER:%/);
assert.match(service, /AS "isOther"/);
assert.match(service, /isOtherFamily: family\.isOther/);
});
});
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string) {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.1 allows direct findings only on Yacimiento, Instalacion and Subinstalacion', () => {
const findings = source('src/inspection-visits/field-findings.service.ts');
assert.match(findings, /\['yacimiento', 'instalacion', 'subinstalacion'\]\.includes\(assetTypeCode\)/);
assert.match(findings, /FIELD_FINDING_TARGET_LEVEL_INVALID/);
assert.match(findings, /Los Hallazgos pueden registrarse sobre Yacimiento, Instalación o Subinstalación/);
assert.match(findings, /assetTypeCode !== 'yacimiento' && !row\.inventoryFamilyId/);
assert.match(findings, /FIELD_FINDING_FAMILY_REQUIRED/);
});
test('F6.1 uses contextual type catalog for Yacimiento and keeps OTROS/add-another everywhere', () => {
const findings = source('src/inspection-visits/field-findings.service.ts');
const resolver = source('src/inspection-findings/f3-finding-catalog-resolver.service.ts');
const catalog = source('src/inspection-findings/finding-catalog.service.ts');
const androidFinding = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt');
assert.match(findings, /canAddAnother: true/);
assert.match(resolver, /typeCode\.toLowerCase\(\) === 'yacimiento'/);
assert.match(resolver, /contextualCatalog\.listApplicableForAsset\(assetId, query\)/);
assert.match(resolver, /catalogSource: 'ASSET_TYPE'/);
assert.match(catalog, /finding_catalog_item_asset_types mapping/);
assert.match(catalog, /finding_catalog_asset_overrides override/);
assert.match(resolver, /code: 'OTHER'/);
assert.match(resolver, /label: 'OTROS'/);
assert.match(androidFinding, /OTROS · No está en el catálogo/);
assert.match(androidFinding, /También podés registrar otro Hallazgo sobre el mismo Inventario/);
});
test('F6.1 exposes mobile planning context and opens an inspection self-assigned to the current inspector', () => {
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
const dto = source('src/inspection-visits/dto/open-mobile-inspection.dto.ts');
assert.match(controller, /@Get\('mobile\/planning-context\/areas'\)/);
assert.match(controller, /@Get\('mobile\/planning-context\/areas\/:areaId\/operators'\)/);
assert.match(controller, /@Post\('mobile\/open'\)/);
assert.match(controller, /@RequirePermissions\('inspections\.execute'\)/);
assert.match(controller, /assertMobileInspector\(principal\)/);
assert.match(controller, /leadInspectorUserId: principal\.userId/);
assert.match(controller, /plannedStartAt: new Date\(\)\.toISOString\(\)/);
assert.match(controller, /this\.lifecycle\.plan\(created\.id/);
assert.match(controller, /this\.lifecycle\.start\(created\.id/);
assert.match(dto, /operationalAreaId!: string/);
assert.match(dto, /operatorCompanyId!: string/);
});
test('F6.1 Android lets the inspector choose Area and current Operator and open the inspection now', () => {
const client = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt');
const home = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt');
const gate = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt');
assert.match(client, /inspection-visits\/mobile\/planning-context\/areas/);
assert.match(client, /inspection-visits\/mobile\/open/);
assert.match(home, /Abrir inspección/);
assert.match(home, /Abrir inspección ahora/);
assert.match(home, /Operadora vigente/);
assert.match(home, /model\.openVisit\(opened\.id\)/);
assert.match(gate, /else -> MobileHomeScreen\(model\)/);
});
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string) {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6 models concrete Installation 1→N Subinstallations while each child keeps exactly one physical parent', () => {
const asset = source('src/database/entities/asset.entity.ts');
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
assert.match(asset, /@Column\(\{ name: 'parent_id', type: 'uuid', nullable: true \}\)/);
assert.match(asset, /parentId!: string \| null/);
assert.match(migration, /IF child_type='subinstalacion' THEN/);
assert.match(migration, /IF NEW\.parent_id IS NULL THEN/);
assert.doesNotMatch(asset, /parentIds!/);
});
test('F6 makes technical Installation↔Subinstallation compatibility truly many-to-many', () => {
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
const catalog = source('src/asset-master/inventory-family-catalog.service.ts');
assert.match(migration, /PRIMARY KEY \(child_family_id,parent_family_id\)/);
assert.match(catalog, /parentFamilyIds: string\[\]/);
assert.match(catalog, /requestedIds:string\[\]/);
assert.match(catalog, /INSERT INTO inventory_family_parent_rules\(child_family_id,parent_family_id\)/);
assert.match(catalog, /UNNEST\(\$2::uuid\[\]\)/);
assert.match(catalog, /ON CONFLICT \(child_family_id,parent_family_id\) DO NOTHING/);
});
test('F6 database rejects a Subinstallation classification incompatible with its concrete Installation parent', () => {
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
assert.match(migration, /SELECT inventory_family_id INTO parent_family FROM assets WHERE id=NEW\.parent_id/);
assert.match(migration, /rule\.child_family_id=NEW\.inventory_family_id AND rule\.parent_family_id=parent_family/);
assert.match(migration, /La clasificación de Subinstalación no es compatible con la Instalación padre/);
});
test('F6 technical values are defined by classification and cannot leak between families', () => {
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
const service = source('src/asset-master/inventory-technical-values.service.ts');
assert.match(migration, /CREATE TABLE IF NOT EXISTS inventory_family_attribute_definitions/);
assert.match(migration, /CREATE TABLE IF NOT EXISTS asset_inventory_attribute_values/);
assert.match(migration, /El campo técnico no pertenece a la clasificación del Inventario/);
assert.match(service, /inventory_family_attribute_definitions/);
assert.match(service, /asset_inventory_attribute_values/);
});
test('F6 field findings resolve by exact technical classification and keep OTROS available', () => {
const resolver = source('src/inspection-findings/f3-finding-catalog-resolver.service.ts');
assert.match(resolver, /mapping\.inventory_family_id=\$1::uuid/);
assert.match(resolver, /asset\.inventory_family_id AS "familyId"/);
assert.doesNotMatch(resolver, /category\.code='f5model'/);
assert.match(resolver, /code: 'OTHER'/);
assert.match(resolver, /label: 'OTROS'/);
});
test('F6 mobile finding gate uses Area ancestry and temporal operator relation instead of copying company ownership onto each object', () => {
const fieldFindings = source('src/inspection-visits/field-findings.service.ts');
assert.match(fieldFindings, /WITH RECURSIVE ancestors/);
assert.match(fieldFindings, /WHERE id=visit\.operational_area_id/);
assert.match(fieldFindings, /FROM area_company_relations relation/);
assert.match(fieldFindings, /relation\.relation_role='OPERATOR'/);
assert.doesNotMatch(fieldFindings, /asset\.operator_company_id=visit\.operator_company_id/);
});
test('F6 field-created technical Inventory may be provisional only until the selected family is assigned', () => {
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
const fieldStructure = source('src/inspection-visits/f3-field-inventory-structure.service.ts');
const fieldFindings = source('src/inspection-visits/field-findings.service.ts');
assert.match(migration, /NEW\.data_origin='FIELD_SURVEY' AND NEW\.information_status='DRAFT'/);
assert.match(fieldStructure, /SET inventory_family_id=\$2::uuid/);
assert.match(fieldFindings, /FIELD_FINDING_FAMILY_REQUIRED/);
});
test('F6 Android keeps search and reloads scoped to the current physical parent', () => {
const viewModel = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
const screen = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt');
assert.match(viewModel, /var inventoryParentId: String\? by mutableStateOf\(null\)/);
assert.match(viewModel, /effectiveParentId = parentId \?: inventoryParentId \?: currentVisit\.operationalArea\?\.id/);
assert.match(viewModel, /repository\.fieldInventory\(currentVisit\.id, null, effectiveParentId\)/);
assert.match(screen, /model\.loadFieldTypes\(item\.id\)/);
});
+1
View File
@@ -0,0 +1 @@
# F6 · Modelo definitivo de Inventarios
+7
View File
@@ -0,0 +1,7 @@
# F6 · Modelo definitivo de Inventarios
La jerarquía física canónica es fija:
Departamento → Área → Yacimiento → Instalación → Subinstalación
Empresa es un maestro independiente y se relaciona temporalmente con Área.
+7
View File
@@ -0,0 +1,7 @@
# F6 · Modelo definitivo de Inventarios
La jerarquía física canónica es fija:
Departamento → Área → Yacimiento → Instalación → Subinstalación
Empresa es un maestro independiente y se relaciona temporalmente con Área.
+8 -2
View File
@@ -247,16 +247,22 @@ echo "========== HEALTH =========="
HEALTH_OK=0
for _ in $(seq 1 60); do
if curl -fsS --max-time 5 http://127.0.0.1:3101/api/v3/health > "$BACKUP/health.json" 2>/dev/null; then
if grep -F '"status":"ok"' "$BACKUP/health.json" >/dev/null; then
if grep -Fq '"status":"ok"' "$BACKUP/health.json" \
&& grep -Fq "\"version\":\"$EXPECTED_API_VERSION\"" "$BACKUP/health.json" \
&& grep -Fq '"database":"ok"' "$BACKUP/health.json"; then
HEALTH_OK=1
break
fi
CURRENT_API_VERSION="$(node -e 'try { const h = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(String(h.version || "unknown")); } catch { process.stdout.write("invalid"); }' "$BACKUP/health.json")"
echo "API respondió pero aún no es la candidata (actual=$CURRENT_API_VERSION, esperada=$EXPECTED_API_VERSION). Reintentando..."
fi
sleep 2
done
if [ "$HEALTH_OK" -ne 1 ]; then
echo "ERROR: API no pasó healthcheck."
echo "ERROR: API candidata no pasó healthcheck/version/database dentro del plazo."
[ ! -f "$BACKUP/health.json" ] || cat "$BACKUP/health.json"
docker compose logs --tail=180 api
false
fi
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-web",
"version": "0.22.0-1",
"version": "0.23.0-1",
"private": true,
"type": "module",
"engines": {
@@ -24,4 +24,4 @@
"typescript": "^5.9.0",
"vite": "^7.0.0"
}
}
}
@@ -1,59 +1,98 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { useAuth } from '../../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { getFindingCatalogAssetSelection, replaceFindingCatalogAssetSelection } from '../../lib/api';
import type { FindingCatalogAssetSelection } from '../../lib/api';
import {
getInventoryFamilyFindings,
getInventoryTechnicalValues,
} from '../../lib/inventoryStructureApi';
import type {
InventoryFamilyFinding,
InventoryTechnicalValues,
} from '../../lib/inventoryStructureApi';
import { AssetTechnicalDataPanel } from './AssetTechnicalDataPanel';
export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: string; canManage: boolean }) {
const [selection, setSelection] = useState<FindingCatalogAssetSelection | null>(null);
const [enabled, setEnabled] = useState<Set<string>>(new Set());
const [reason, setReason] = useState('');
const { hasPermission } = useAuth();
const canEditTechnical = hasPermission('assets.update');
const [technical, setTechnical] = useState<InventoryTechnicalValues | null>(null);
const [items, setItems] = useState<InventoryFamilyFinding[]>([]);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [unsupported, setUnsupported] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const load = () => getFindingCatalogAssetSelection(assetId).then((loaded) => {
setSelection(loaded);
setEnabled(new Set(loaded.items.filter((item) => item.enabled).map((item) => item.id)));
});
useEffect(() => { load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, [assetId]);
useEffect(() => {
let active = true;
setLoading(true);
setUnsupported(false);
setError('');
getInventoryTechnicalValues(assetId)
.then(async (loaded) => {
const findings = await getInventoryFamilyFindings(loaded.family.id);
if (!active) return;
setTechnical(loaded);
setItems(findings.items);
})
.catch((requestError) => {
if (!active) return;
const message = errorMessage(requestError);
if (message.includes('no tiene clasificación técnica')) setUnsupported(true);
else setError(message);
})
.finally(() => active && setLoading(false));
return () => { active = false; };
}, [assetId]);
const visible = useMemo(() => {
const needle = search.trim().toLocaleLowerCase();
return selection?.items.filter((item) => !needle || [item.title, item.code, item.categoryName]
.some((value) => value.toLocaleLowerCase().includes(needle))) ?? [];
}, [selection, search]);
if (!needle) return items;
return items.filter((item) => [item.title, item.code, item.categoryName, item.legalBasis ?? '']
.some((value) => value.toLocaleLowerCase().includes(needle)));
}, [items, search]);
const toggle = (id: string) => setEnabled((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const save = async () => {
setSaving(true); setError(''); setSuccess('');
try {
const saved = await replaceFindingCatalogAssetSelection(assetId, { enabledItemIds: [...enabled], reason });
setSelection(saved);
setEnabled(new Set(saved.items.filter((item) => item.enabled).map((item) => item.id)));
setReason('');
setSuccess('Subconjunto de hallazgos actualizado para este objeto.');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando hallazgos aplicables…" /></div>;
if (!selection) return <Alert>{error || 'No se pudo cargar la configuración.'}</Alert>;
return <section className="panel asset-finding-catalog-panel">
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS APLICABLES</span><h2>{selection.asset.name}</h2><p className="section-copy">Base: {selection.asset.assetTypeName}. Las excepciones de esta pantalla afectan sólo a este objeto del Inventario.</p></div><span className="count-pill">{enabled.size} habilitados</span></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{!selection.typeConfigured && <div className="temporal-notice"><Icon name="alert" /><p><strong>El tipo técnico todavía no tiene un catálogo restringido.</strong> Por compatibilidad, su base actual incluye todo el catálogo activo. Podés configurar primero el tipo general en Catálogo de hallazgos.</p></div>}
<label className="search-field"><Icon name="search" /><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Buscar hallazgo aplicable…" /></label>
<div className="finding-selection-list">{visible.map((item) => <label className={`finding-selection-row ${item.assetOverride !== null ? 'override' : ''}`} key={item.id}><input type="checkbox" disabled={!canManage} checked={enabled.has(item.id)} onChange={() => toggle(item.id)} /><span><strong>{item.title}</strong><small>{item.categoryName} · {item.code} · base del tipo: {item.typeDefaultEnabled ? 'sí' : 'no'}{item.assetOverride !== null ? ' · excepción de este objeto' : ''}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label>)}</div>
{canManage && <><label className="field"><span>Motivo de la excepción</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} placeholder="Explicá por qué este objeto usa un subconjunto distinto…" /></label><div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar excepciones'}</button></div></>}
if (loading) return <div className="panel"><LoadingBlock label="Cargando clasificación y Hallazgos aplicables…" /></div>;
if (error) return <Alert>{error}</Alert>;
if (unsupported || !technical) return <section className="panel asset-finding-catalog-panel">
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS APLICABLES</span><h2>Sin clasificación técnica</h2><p className="section-copy">Departamento, Área y Yacimiento son niveles estructurales. Los Hallazgos se registran sobre Instalaciones y Subinstalaciones clasificadas.</p></div></div>
</section>;
}
return <div className="asset-tab-stack">
<AssetTechnicalDataPanel assetId={assetId} canEdit={canEditTechnical} />
<section className="panel asset-finding-catalog-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">HALLAZGOS APLICABLES</span>
<h2>{technical.family.name}</h2>
<p className="section-copy">Se muestran únicamente los Hallazgos asociados a esta clasificación técnica. Éste es el mismo catálogo que consume la APK para este objeto.</p>
</div>
<span className="count-pill">{items.length} asociados</span>
</div>
{canManage && <div className="temporal-notice">
<Icon name="layers" />
<p><strong>¿Falta un Hallazgo?</strong> Administrá la clasificación en Configuración de Inventarios y el cambio se aplicará a todos los objetos de este mismo tipo técnico.</p>
<Link className="button secondary" to="/admin/asset-types">Configurar Inventarios</Link>
</div>}
{items.length > 0 && <label className="search-field">
<Icon name="search" />
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={`Buscar entre los ${items.length} Hallazgos asociados…`} />
</label>}
{items.length === 0
? <div className="inline-empty">Esta clasificación todavía no tiene Hallazgos asociados. Desde Configuración de Inventarios podés vincular los que correspondan.</div>
: visible.length === 0
? <div className="inline-empty">No hay Hallazgos asociados que coincidan con la búsqueda.</div>
: <div className="finding-selection-list">{visible.map((item) => <div className="finding-selection-row" key={item.id}>
<span className="asset-symbol"><Icon name="alert" size={15} /></span>
<span>
<strong>{item.title}</strong>
<small>{item.categoryName} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small>
{item.legalBasis && <small>{item.legalBasis}</small>}
</span>
</div>)}</div>}
</section>
</div>;
}
@@ -0,0 +1,62 @@
import { useEffect, useState } from 'react';
import { SearchableSelect } from '../../components/SearchableSelect';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { getInventoryTechnicalValues, replaceInventoryTechnicalValues } from '../../lib/inventoryStructureApi';
import type { InventoryFamilyAttribute, InventoryTechnicalValues } from '../../lib/inventoryStructureApi';
function localDateTime(value:unknown){
if(typeof value!=='string'||!value)return '';
const date=new Date(value); if(Number.isNaN(date.getTime()))return value.slice(0,16);
const local=new Date(date.getTime()-date.getTimezoneOffset()*60000); return local.toISOString().slice(0,16);
}
function displayValue(definition:InventoryFamilyAttribute,value:unknown){
if(value===undefined||value===null||value==='')return 'Sin completar';
if(definition.dataType==='BOOLEAN')return value?'Sí':'No';
if(definition.dataType==='DATETIME'&&typeof value==='string')return new Date(value).toLocaleString('es-AR');
return `${String(value)}${definition.unit?` ${definition.unit}`:''}`;
}
export function AssetTechnicalDataPanel({assetId,canEdit}:{assetId:string;canEdit:boolean}){
const [data,setData]=useState<InventoryTechnicalValues|null>(null);
const [values,setValues]=useState<Record<string,unknown>>({});
const [loading,setLoading]=useState(true); const [saving,setSaving]=useState(false);
const [unsupported,setUnsupported]=useState(false); const [error,setError]=useState(''); const [success,setSuccess]=useState('');
useEffect(()=>{
let active=true; setLoading(true); setUnsupported(false); setError('');
getInventoryTechnicalValues(assetId).then((loaded)=>{if(!active)return;setData(loaded);setValues(Object.fromEntries(loaded.definitions.map((definition)=>{
const raw=loaded.values[definition.id]; return [definition.id,definition.dataType==='DATETIME'?localDateTime(raw):raw??''];
})))}).catch((requestError)=>{if(!active)return;const message=errorMessage(requestError);if(message.includes('no tiene clasificación técnica'))setUnsupported(true);else setError(message);}).finally(()=>active&&setLoading(false));
return()=>{active=false};
},[assetId]);
if(loading)return <div className="form-section"><LoadingBlock label="Cargando datos técnicos…" /></div>;
if(unsupported)return null;
if(error&&!data)return <div className="form-section"><Alert>{error}</Alert></div>;
if(!data)return null;
const activeDefinitions=data.definitions.filter((item)=>item.isActive);
const save=async()=>{
setSaving(true);setError('');setSuccess('');
try{
const normalized:Record<string,unknown>={};
for(const definition of activeDefinitions){const raw=values[definition.id];if(raw===''||raw===null||raw===undefined)continue;
if(definition.dataType==='NUMBER')normalized[definition.id]=Number(raw);
else if(definition.dataType==='BOOLEAN')normalized[definition.id]=Boolean(raw);
else if(definition.dataType==='DATETIME')normalized[definition.id]=new Date(String(raw)).toISOString();
else normalized[definition.id]=raw;
}
const saved=await replaceInventoryTechnicalValues(assetId,normalized);setData(saved);setSuccess('Datos técnicos guardados.');
}catch(requestError){setError(errorMessage(requestError));}finally{setSaving(false)}
};
return <div className="form-section">
<div><h2>Información técnica · {data.family.name}</h2><p className="section-copy">Estos campos pertenecen a la clasificación concreta del elemento, no al nivel genérico.</p></div>
{error&&<Alert>{error}</Alert>}{success&&<Alert type="success">{success}</Alert>}
{activeDefinitions.length===0?<div className="inline-empty">Esta clasificación todavía no tiene campos técnicos configurados.</div>:canEdit?<div className="dynamic-attributes">{activeDefinitions.map((definition)=>{
const value=values[definition.id];const label=<span>{definition.name}{definition.unit?` (${definition.unit})`:''}{definition.isRequired?<em>obligatorio</em>:<em>opcional</em>}</span>;
if(definition.dataType==='BOOLEAN')return <label className="check-row attribute-check" key={definition.id}><input type="checkbox" checked={Boolean(value)} onChange={(event)=>setValues((current)=>({...current,[definition.id]:event.target.checked}))}/><span><strong>{definition.name}</strong><small>{definition.code}</small></span></label>;
if(definition.dataType==='SELECT')return <label className="field" key={definition.id}>{label}<SearchableSelect value={String(value??'')} onChange={(event)=>setValues((current)=>({...current,[definition.id]:event.target.value}))} required={definition.isRequired}><option value="">Seleccionar</option>{definition.options?.map((option)=><option key={option} value={option}>{option}</option>)}</SearchableSelect></label>;
const inputType=definition.dataType==='NUMBER'?'number':definition.dataType==='DATE'?'date':definition.dataType==='DATETIME'?'datetime-local':'text';
return <label className="field" key={definition.id}>{label}<input type={inputType} value={String(value??'')} step={definition.dataType==='NUMBER'?'any':undefined} required={definition.isRequired} onChange={(event)=>setValues((current)=>({...current,[definition.id]:event.target.value}))}/></label>;
})}</div>:<div className="attribute-list">{activeDefinitions.map((definition)=><div className="attribute-card" key={definition.id}><span className="asset-symbol"><Icon name="layers" size={15}/></span><span><strong>{definition.name}</strong><small>{definition.code}</small></span><span>{displayValue(definition,data.values[definition.id])}</span></div>)}</div>}
{canEdit&&activeDefinitions.length>0&&<div className="form-actions"><button type="button" className="button primary" disabled={saving} onClick={()=>void save()}><Icon name="check" />{saving?'Guardando…':'Guardar datos técnicos'}</button></div>}
</div>;
}
@@ -12,9 +12,14 @@ import {
import type { InventoryFamily } from '../../lib/inventoryStructureApi';
const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
type ViewMode = 'ASSOCIATED' | 'ALL';
function familyContext(family: InventoryFamily) {
if (family.level === 'INSTALLATION') return `Instalación · ${family.name}`;
const parents = family.parentFamilies.map((parent) => parent.name).join(', ');
return `Subinstalación · ${family.name}${parents ? ` · compatible con ${parents}` : ''}`;
}
export function FindingCatalogTypeApplicabilityPanel() {
const [searchParams] = useSearchParams();
const requestedFamilyId = searchParams.get('familyId') ?? '';
@@ -37,137 +42,50 @@ export function FindingCatalogTypeApplicabilityPanel() {
Promise.all([listInventoryFamiliesAdmin(), getFindingCatalogAdmin()])
.then(([loadedFamilies, loadedCatalog]) => {
const activeFamilies = loadedFamilies.filter((family) => family.isActive !== false);
setFamilies(activeFamilies);
setCatalog(loadedCatalog);
setFamilyId(activeFamilies.some((family) => family.id === requestedFamilyId)
? requestedFamilyId
: activeFamilies[0]?.id ?? '');
setFamilies(activeFamilies); setCatalog(loadedCatalog);
setFamilyId(activeFamilies.some((family) => family.id === requestedFamilyId) ? requestedFamilyId : activeFamilies[0]?.id ?? '');
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
.catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
}, [requestedFamilyId]);
useEffect(() => {
const selected = families.find((family) => family.id === familyId);
setEnabled(new Set(selected?.findingItemIds ?? []));
setViewMode('ASSOCIATED');
setSearch('');
setSuccess('');
setError('');
setEnabled(new Set(selected?.findingItemIds ?? [])); setViewMode('ASSOCIATED'); setSearch(''); setSuccess(''); setError('');
}, [familyId, families]);
const activeCategoryIds = useMemo(() => new Set(
catalog.categories.filter((category) => category.isActive).map((category) => category.id),
), [catalog.categories]);
const categoryName = useMemo(() => new Map(
catalog.categories.map((category) => [category.id, category.name]),
), [catalog.categories]);
const activeCategoryIds = useMemo(() => new Set(catalog.categories.filter((category) => category.isActive).map((category) => category.id)), [catalog.categories]);
const categoryName = useMemo(() => new Map(catalog.categories.map((category) => [category.id, category.name])), [catalog.categories]);
const available = useMemo(() => {
const needle = search.trim().toLocaleLowerCase('es-AR');
return catalog.items.filter((item) =>
item.isActive
&& activeCategoryIds.has(item.categoryId)
&& (!needle || [item.title, item.code, categoryName.get(item.categoryId) ?? '']
.some((value) => value.toLocaleLowerCase('es-AR').includes(needle))),
);
return catalog.items.filter((item) => item.isActive && activeCategoryIds.has(item.categoryId)
&& (!needle || [item.title, item.code, categoryName.get(item.categoryId) ?? ''].some((value) => value.toLocaleLowerCase('es-AR').includes(needle))));
}, [catalog.items, search, activeCategoryIds, categoryName]);
const visible = useMemo(() => viewMode === 'ASSOCIATED' ? available.filter((item) => savedIds.has(item.id)) : available, [available, savedIds, viewMode]);
const visible = useMemo(() => viewMode === 'ASSOCIATED'
? available.filter((item) => savedIds.has(item.id))
: available,
[available, savedIds, viewMode]);
const toggle = (id: string) => setEnabled((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const selectVisible = () => setEnabled((current) => {
const next = new Set(current);
visible.forEach((item) => next.add(item.id));
return next;
});
const toggle = (id: string) => setEnabled((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; });
const selectVisible = () => setEnabled((current) => { const next = new Set(current); visible.forEach((item) => next.add(item.id)); return next; });
const save = async () => {
if (!selectedFamily) return;
setSaving(true); setError(''); setSuccess('');
try {
const saved = await replaceInventoryFamilyFindings(selectedFamily.id, {
itemIds: [...enabled],
reason,
});
const itemIds = saved.items.map((item) => item.id);
setEnabled(new Set(itemIds));
setFamilies((current) => current.map((family) => family.id === selectedFamily.id
? { ...family, findingItemIds: itemIds, findingCount: itemIds.length }
: family));
setViewMode('ASSOCIATED');
setSuccess(`Hallazgos guardados para ${selectedFamily.name}.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
const saved = await replaceInventoryFamilyFindings(selectedFamily.id, { itemIds: [...enabled], reason });
const itemIds = saved.items.map((item) => item.id); setEnabled(new Set(itemIds));
setFamilies((current) => current.map((family) => family.id === selectedFamily.id ? { ...family, findingItemIds: itemIds, findingCount: itemIds.length } : family));
setViewMode('ASSOCIATED'); setSuccess(`Hallazgos guardados para ${selectedFamily.name}.`);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando aplicabilidad…" /></div>;
if (families.length === 0) return <Alert>No hay clasificaciones de Instalación/Subinstalación disponibles. Crealas primero en Configuración de Inventarios.</Alert>;
return <section className="panel finding-applicability-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">APLICABILIDAD POR INSTALACIÓN / SUBINSTALACIÓN</span>
<h2>Qué Hallazgos verá el inspector</h2>
<p className="section-copy">Al elegir una clasificación se filtra inmediatamente a sus Hallazgos asociados. Para modificar la relación, abrí Todos para vincular.</p>
</div>
<span className="count-pill">{savedIds.size} vinculados</span>
</div>
<div className="panel-heading"><div><span className="eyebrow">APLICABILIDAD POR CLASIFICACIÓN</span><h2>Qué Hallazgos verá el inspector</h2><p className="section-copy">Al elegir una clasificación se muestran sólo sus Hallazgos asociados. Todos para vincular se usa exclusivamente para modificar la relación.</p></div><span className="count-pill">{savedIds.size} vinculados</span></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="form-grid finding-applicability-toolbar">
<label className="field">
<span>Clasificación de Inventario</span>
<SearchableSelect value={familyId} onChange={(event) => setFamilyId(event.target.value)}>
{families.map((family) => <option value={family.id} key={family.id}>
{family.level === 'INSTALLATION' ? 'Instalación' : 'Subinstalación'} · {family.parentFamilyName ? `${family.parentFamilyName}` : ''}{family.name}
</option>)}
</SearchableSelect>
</label>
<label className="field"><span>Buscar dentro de {viewMode === 'ASSOCIATED' ? 'los asociados' : 'todo el Catálogo'}</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>
</div>
{selectedFamily && <div className="temporal-notice">
<Icon name="layers" />
<p><strong>{selectedFamily.level === 'INSTALLATION' ? 'Instalación' : 'Subinstalación'}:</strong> {selectedFamily.parentFamilyName ? `${selectedFamily.parentFamilyName}` : ''}{selectedFamily.name}. Tiene {savedIds.size} Hallazgo{savedIds.size === 1 ? '' : 's'} asociado{savedIds.size === 1 ? '' : 's'}.</p>
</div>}
<div className="quick-view-row" style={{ marginBottom: 12 }}>
<button type="button" className={viewMode === 'ASSOCIATED' ? 'active' : ''} onClick={() => { setViewMode('ASSOCIATED'); setSearch(''); }}>Asociados ({savedIds.size})</button>
<button type="button" className={viewMode === 'ALL' ? 'active' : ''} onClick={() => { setViewMode('ALL'); setSearch(''); }}>Todos para vincular ({catalog.items.filter((item) => item.isActive && activeCategoryIds.has(item.categoryId)).length})</button>
</div>
{viewMode === 'ASSOCIATED' && visible.length === 0
? <div className="inline-empty"><strong>{search.trim() ? 'Ningún Hallazgo asociado coincide con la búsqueda.' : 'Esta clasificación todavía no tiene Hallazgos asociados.'}</strong><br />{!search.trim() && 'Abrí “Todos para vincular” para elegirlos.'}</div>
: <>
{viewMode === 'ALL' && <div className="catalog-selection-actions">
<button type="button" className="button secondary" onClick={selectVisible}>Seleccionar visibles</button>
<button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button>
</div>}
<div className="finding-selection-list">{visible.map((item) => viewMode === 'ALL'
? <label className="finding-selection-row" key={item.id}>
<input type="checkbox" checked={enabled.has(item.id)} onChange={() => toggle(item.id)} />
<span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span>
</label>
: <div className="finding-selection-row" key={item.id}>
<span className="asset-symbol"><Icon name="check" size={14} /></span>
<span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span>
</div>)}</div>
</>}
{viewMode === 'ALL' && <>
<label className="field"><span>Motivo del cambio</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} /></label>
<div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5 || !selectedFamily} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : `Guardar ${enabled.size} asociados`}</button></div>
</>}
<div className="form-grid finding-applicability-toolbar"><label className="field"><span>Clasificación de Inventario</span><SearchableSelect value={familyId} onChange={(event) => setFamilyId(event.target.value)}>{families.map((family) => <option value={family.id} key={family.id}>{familyContext(family)}</option>)}</SearchableSelect></label><label className="field"><span>Buscar dentro de {viewMode === 'ASSOCIATED' ? 'los asociados' : 'todo el Catálogo'}</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Título, código o categoría…" /></label></div>
{selectedFamily && <div className="temporal-notice"><Icon name="layers" /><p><strong>{selectedFamily.name}:</strong> {selectedFamily.level === 'SUBINSTALLATION' && selectedFamily.parentFamilies.length ? `compatible con ${selectedFamily.parentFamilies.map((parent) => parent.name).join(', ')}. ` : ''}Tiene {savedIds.size} Hallazgo{savedIds.size === 1 ? '' : 's'} asociado{savedIds.size === 1 ? '' : 's'}.</p></div>}
<div className="quick-view-row" style={{ marginBottom: 12 }}><button type="button" className={viewMode === 'ASSOCIATED' ? 'active' : ''} onClick={() => { setViewMode('ASSOCIATED'); setSearch(''); }}>Asociados ({savedIds.size})</button><button type="button" className={viewMode === 'ALL' ? 'active' : ''} onClick={() => { setViewMode('ALL'); setSearch(''); }}>Todos para vincular ({catalog.items.filter((item) => item.isActive && activeCategoryIds.has(item.categoryId)).length})</button></div>
{viewMode === 'ASSOCIATED' && visible.length === 0 ? <div className="inline-empty"><strong>{search.trim() ? 'Ningún Hallazgo asociado coincide con la búsqueda.' : 'Esta clasificación todavía no tiene Hallazgos asociados.'}</strong><br />{!search.trim() && 'Abrí “Todos para vincular” para elegirlos.'}</div> : <>{viewMode === 'ALL' && <div className="catalog-selection-actions"><button type="button" className="button secondary" onClick={selectVisible}>Seleccionar visibles</button><button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button></div>}<div className="finding-selection-list">{visible.map((item) => viewMode === 'ALL' ? <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={enabled.has(item.id)} onChange={() => toggle(item.id)} /><span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label> : <div className="finding-selection-row" key={item.id}><span className="asset-symbol"><Icon name="check" size={14} /></span><span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></div>)}</div></>}
{viewMode === 'ALL' && <><label className="field"><span>Motivo del cambio</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} /></label><div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5 || !selectedFamily} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : `Guardar ${enabled.size} asociados`}</button></div></>}
</section>;
}
+40 -147
View File
@@ -1,163 +1,56 @@
import { apiRequest } from './api';
export type InventoryStructureKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
export type InventoryFamilyAttributeDataType = 'TEXT' | 'NUMBER' | 'BOOLEAN' | 'DATE' | 'DATETIME' | 'SELECT';
export interface InventoryFamilyParent { id:string; code:string; name:string; }
export interface InventoryFamily {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
legacyTypeCode?: string | null;
informationLabels: string[];
sourceReference?: string | null;
isActive?: boolean;
parentFamilyId: string | null;
parentFamilyCode: string | null;
parentFamilyName: string | null;
assetCount?: number;
findingCount?: number;
findingItemIds?: string[];
id:string; code:string; name:string; level:'INSTALLATION'|'SUBINSTALLATION'; legacyTypeCode?:string|null;
informationLabels:string[]; sourceReference?:string|null; isActive?:boolean; parentFamilyIds:string[];
parentFamilies:InventoryFamilyParent[]; assetCount?:number; findingCount?:number; findingItemIds?:string[]; technicalAttributeCount?:number;
}
export interface InventoryStructureLevel {
kind: InventoryStructureKind;
label: string;
type: { id: string; code: string; name: string };
parentKind: InventoryStructureKind | null;
requiresFamily: boolean;
export interface InventoryFamilyAttribute {
id:string; inventoryFamilyId:string; code:string; name:string; dataType:InventoryFamilyAttributeDataType;
isRequired:boolean; isActive:boolean; unit:string|null; options:string[]|null; sortOrder:number;
}
export interface InventoryStructureOptions {
independentMasters: InventoryStructureLevel[];
levels: InventoryStructureLevel[];
installationFamilies: InventoryFamily[];
subinstallationFamilies: InventoryFamily[];
export interface InventoryFamilyAttributes { family:InventoryFamily; items:InventoryFamilyAttribute[]; count:number; }
export interface InventoryTechnicalValues {
assetId:string;
family:{id:string;code:string;name:string;level:string};
definitions:InventoryFamilyAttribute[];
values:Record<string,unknown>;
}
export interface InventoryStructureLevel { kind:InventoryStructureKind; label:string; type:{id:string;code:string;name:string}; parentKind:InventoryStructureKind|null; requiresFamily:boolean; }
export interface InventoryStructureOptions { independentMasters:InventoryStructureLevel[]; levels:InventoryStructureLevel[]; installationFamilies:InventoryFamily[]; subinstallationFamilies:InventoryFamily[]; }
export interface InventoryStructureParent {
id: string;
code: string;
name: string;
commonName: string | null;
type: { id: string; code: string; name: string };
inventoryFamily: null | {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
informationLabels: string[];
};
parent: null | { id: string; code: string; name: string };
id:string; code:string; name:string; commonName:string|null; type:{id:string;code:string;name:string};
inventoryFamily:null|{id:string;code:string;name:string;level:'INSTALLATION'|'SUBINSTALLATION';informationLabels:string[]};
parent:null|{id:string;code:string;name:string};
}
export interface InventoryFamilyFinding {
id: string;
code: string;
sourceNumber: number;
title: string;
legalBasis: string | null;
glossary: string | null;
suggestedSeverity: number | null;
categoryId: string;
categoryCode: string;
categoryName: string;
id:string; code:string; sourceNumber:number; title:string; legalBasis:string|null; glossary:string|null;
suggestedSeverity:number|null; categoryId:string; categoryCode:string; categoryName:string;
}
export interface InventoryFamilyFindings {
family: {
id: string;
code: string;
name: string;
level: string;
informationLabels: string[];
};
items: InventoryFamilyFinding[];
count: number;
}
export interface InventoryFamilyFindings { family:InventoryFamily; items:InventoryFamilyFinding[]; count:number; }
export interface CreatedInventoryStructure {
id: string;
code: string;
name: string;
commonName: string | null;
description: string | null;
informationStatus: string;
operationalStatus: string;
type: { id: string; code: string; name: string };
parent: null | { id: string; code: string; name: string };
operationalArea?: null | { id: string; code: string; name: string };
inventoryFamily: null | {
id: string;
code: string;
name: string;
level: string;
informationLabels: string[];
};
id:string; code:string; name:string; commonName:string|null; description:string|null; informationStatus:string; operationalStatus:string;
type:{id:string;code:string;name:string}; parent:null|{id:string;code:string;name:string}; operationalArea?:null|{id:string;code:string;name:string};
inventoryFamily:null|{id:string;code:string;name:string;level:string;informationLabels:string[]};
}
export function getInventoryStructureOptions() {
return apiRequest<InventoryStructureOptions>('/inventory-structure');
}
export async function listInventoryStructureParents(kind: InventoryStructureKind, search = '') {
const query = new URLSearchParams();
if (search.trim()) query.set('search', search.trim());
const suffix = query.size ? `?${query}` : '';
return (await apiRequest<{ data: InventoryStructureParent[] }>(
`/inventory-structure/parents/${kind}${suffix}`,
)).data;
}
export function getInventoryFamilyFindings(familyId: string) {
return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`);
}
export async function listInventoryFamiliesAdmin() {
return (await apiRequest<{ data: InventoryFamily[] }>('/inventory-families/admin')).data;
}
export function createInventoryFamily(input: {
level: 'INSTALLATION' | 'SUBINSTALLATION';
name: string;
parentFamilyId?: string | null;
informationLabels?: string[];
}) {
return apiRequest<InventoryFamily>('/inventory-families', {
method: 'POST', body: JSON.stringify(input),
});
}
export function updateInventoryFamily(familyId: string, input: {
name?: string;
parentFamilyId?: string | null;
informationLabels?: string[];
isActive?: boolean;
}) {
return apiRequest<InventoryFamily>(`/inventory-families/${familyId}`, {
method: 'PATCH', body: JSON.stringify(input),
});
}
export function replaceInventoryFamilyFindings(familyId: string, input: {
itemIds: string[];
reason: string;
}) {
return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`, {
method: 'PUT', body: JSON.stringify(input),
});
}
export function createInventoryStructure(input: {
kind: InventoryStructureKind;
code?: string | null;
name: string;
commonName?: string | null;
parentId?: string | null;
familyId?: string | null;
description?: string | null;
}) {
return apiRequest<CreatedInventoryStructure>('/inventory-structure', {
method: 'POST',
body: JSON.stringify(input),
});
export function getInventoryStructureOptions(){return apiRequest<InventoryStructureOptions>('/inventory-structure');}
export async function listInventoryStructureParents(kind:InventoryStructureKind,search=''){
const query=new URLSearchParams(); if(search.trim()) query.set('search',search.trim()); const suffix=query.size?`?${query}`:'';
return (await apiRequest<{data:InventoryStructureParent[]}>(`/inventory-structure/parents/${kind}${suffix}`)).data;
}
export function getInventoryFamilyFindings(familyId:string){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`);}
export function getInventoryFamilyAttributes(familyId:string){return apiRequest<InventoryFamilyAttributes>(`/inventory-families/${familyId}/attributes`);}
export function getInventoryTechnicalValues(assetId:string){return apiRequest<InventoryTechnicalValues>(`/assets/${assetId}/technical-values`);}
export function replaceInventoryTechnicalValues(assetId:string,values:Record<string,unknown>){return apiRequest<InventoryTechnicalValues>(`/assets/${assetId}/technical-values`,{method:'PUT',body:JSON.stringify({values})});}
export async function listInventoryFamiliesAdmin(){return (await apiRequest<{data:InventoryFamily[]}>('/inventory-families/admin')).data;}
export function createInventoryFamily(input:{level:'INSTALLATION'|'SUBINSTALLATION';name:string;parentFamilyIds?:string[];informationLabels?:string[]}){return apiRequest<InventoryFamily>('/inventory-families',{method:'POST',body:JSON.stringify(input)});}
export function updateInventoryFamily(familyId:string,input:{name?:string;parentFamilyIds?:string[];informationLabels?:string[];isActive?:boolean}){return apiRequest<InventoryFamily>(`/inventory-families/${familyId}`,{method:'PATCH',body:JSON.stringify(input)});}
export function createInventoryFamilyAttribute(familyId:string,input:{code:string;name:string;dataType:InventoryFamilyAttributeDataType;isRequired?:boolean;unit?:string|null;options?:string[];sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes`,{method:'POST',body:JSON.stringify(input)});}
export function updateInventoryFamilyAttribute(familyId:string,attributeId:string,input:{name?:string;dataType?:InventoryFamilyAttributeDataType;isRequired?:boolean;isActive?:boolean;unit?:string|null;options?:string[]|null;sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes/${attributeId}`,{method:'PATCH',body:JSON.stringify(input)});}
export function replaceInventoryFamilyFindings(familyId:string,input:{itemIds:string[];reason:string}){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`,{method:'PUT',body:JSON.stringify(input)});}
export function createInventoryStructure(input:{kind:InventoryStructureKind;code?:string|null;name:string;commonName?:string|null;parentId?:string|null;familyId?:string|null;description?:string|null}){return apiRequest<CreatedInventoryStructure>('/inventory-structure',{method:'POST',body:JSON.stringify(input)});}
+114 -108
View File
@@ -19,11 +19,18 @@ import type {
} from '../lib/api';
import {
createInventoryFamily,
createInventoryFamilyAttribute,
getInventoryFamilyAttributes,
listInventoryFamiliesAdmin,
replaceInventoryFamilyFindings,
updateInventoryFamily,
updateInventoryFamilyAttribute,
} from '../lib/inventoryStructureApi';
import type {
InventoryFamily,
InventoryFamilyAttribute,
InventoryFamilyAttributeDataType,
} from '../lib/inventoryStructureApi';
import type { InventoryFamily } from '../lib/inventoryStructureApi';
const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> = [
{ value: 'TEXT', label: 'Texto' },
@@ -33,12 +40,12 @@ const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> =
{ value: 'DATETIME', label: 'Fecha y hora' },
{ value: 'SELECT', label: 'Lista de opciones' },
];
const EMPTY_FINDING_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
type CanonicalKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
type FamilyEditor = InventoryFamily | 'new' | null;
type FamilyFindingMode = 'ASSOCIATED' | 'ALL';
type TechnicalEditor = InventoryFamilyAttribute | 'new' | null;
const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string }> = [
{ kind: 'EMPRESA', label: 'Empresa', description: 'Maestro independiente. Se vincula temporalmente a un Área.' },
@@ -55,11 +62,9 @@ function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | nul
const code = kind.toLowerCase();
return types.find((type) => type.code.toLowerCase() === code && type.isActive) ?? null;
}
function attributeTypeLabel(value: AssetAttributeDataType) {
function attributeTypeLabel(value: AssetAttributeDataType | InventoryFamilyAttributeDataType) {
return ATTRIBUTE_TYPES.find((item) => item.value === value)?.label ?? value;
}
function attributeCodeFromName(value: string) {
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim()
.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 80);
@@ -68,6 +73,7 @@ function attributeCodeFromName(value: string) {
export function AssetTypesPage() {
const { hasPermission } = useAuth();
const canManage = hasPermission('asset_types.manage');
const canManageFindings = hasPermission('finding_catalog.manage');
const [types, setTypes] = useState<AssetType[]>([]);
const [families, setFamilies] = useState<InventoryFamily[]>([]);
const [findingCatalog, setFindingCatalog] = useState<FindingAdminCatalog>(EMPTY_FINDING_CATALOG);
@@ -81,11 +87,22 @@ export function AssetTypesPage() {
const [familyEditor, setFamilyEditor] = useState<FamilyEditor>(null);
const [familyLevel, setFamilyLevel] = useState<'INSTALLATION' | 'SUBINSTALLATION'>('INSTALLATION');
const [familyName, setFamilyName] = useState('');
const [familyParentId, setFamilyParentId] = useState('');
const [familyParentIds, setFamilyParentIds] = useState<Set<string>>(new Set());
const [familyActive, setFamilyActive] = useState(true);
const [familyFindingIds, setFamilyFindingIds] = useState<Set<string>>(new Set());
const [familyFindingMode, setFamilyFindingMode] = useState<FamilyFindingMode>('ASSOCIATED');
const [familyFindingSearch, setFamilyFindingSearch] = useState('');
const [technicalAttributes, setTechnicalAttributes] = useState<InventoryFamilyAttribute[]>([]);
const [technicalLoading, setTechnicalLoading] = useState(false);
const [technicalEditor, setTechnicalEditor] = useState<TechnicalEditor>(null);
const [technicalCode, setTechnicalCode] = useState('');
const [technicalName, setTechnicalName] = useState('');
const [technicalType, setTechnicalType] = useState<InventoryFamilyAttributeDataType>('TEXT');
const [technicalRequired, setTechnicalRequired] = useState(false);
const [technicalActive, setTechnicalActive] = useState(true);
const [technicalUnit, setTechnicalUnit] = useState('');
const [technicalOptions, setTechnicalOptions] = useState('');
const [technicalOrder, setTechnicalOrder] = useState(0);
const [attributeEditor, setAttributeEditor] = useState<AssetAttributeDefinition | 'new' | null>(null);
const [attributeCode, setAttributeCode] = useState('');
@@ -99,15 +116,10 @@ export function AssetTypesPage() {
const load = async () => {
const [loadedTypes, loadedFamilies, loadedFindingCatalog] = await Promise.all([
listAssetTypes(),
listInventoryFamiliesAdmin(),
getFindingCatalogAdmin(),
listAssetTypes(), listInventoryFamiliesAdmin(), getFindingCatalogAdmin(),
]);
setTypes(loadedTypes);
setFamilies(loadedFamilies);
setFindingCatalog(loadedFindingCatalog);
setTypes(loadedTypes); setFamilies(loadedFamilies); setFindingCatalog(loadedFindingCatalog);
};
useEffect(() => {
load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
}, []);
@@ -116,7 +128,7 @@ export function AssetTypesPage() {
const installationFamilies = useMemo(() => families.filter((item) => item.level === 'INSTALLATION'), [families]);
const subinstallationFamilies = useMemo(() => families.filter((item) => item.level === 'SUBINSTALLATION'), [families]);
const visibleSubinstallationFamilies = useMemo(() => subinstallationFamilies.filter((family) =>
family.isActive !== false && (!selectedInstallationFamilyId || family.parentFamilyId === selectedInstallationFamilyId),
family.isActive !== false && (!selectedInstallationFamilyId || family.parentFamilyIds.includes(selectedInstallationFamilyId)),
), [subinstallationFamilies, selectedInstallationFamilyId]);
const activeFindingCategoryIds = useMemo(() => new Set(
@@ -138,48 +150,79 @@ export function AssetTypesPage() {
const openNewFamily = (level: 'INSTALLATION' | 'SUBINSTALLATION') => {
setFamilyEditor('new'); setFamilyLevel(level); setFamilyName('');
setFamilyParentId(level === 'SUBINSTALLATION' ? selectedInstallationFamilyId : '');
setFamilyParentIds(new Set(level === 'SUBINSTALLATION' && selectedInstallationFamilyId ? [selectedInstallationFamilyId] : []));
setFamilyActive(true); setFamilyFindingIds(new Set()); setFamilyFindingMode('ASSOCIATED'); setFamilyFindingSearch('');
setError(''); setSuccess('');
setTechnicalAttributes([]); setTechnicalEditor(null); setError(''); setSuccess('');
};
const openFamily = (family: InventoryFamily) => {
const openFamily = async (family: InventoryFamily) => {
if (family.level === 'INSTALLATION') setSelectedInstallationFamilyId(family.id);
setFamilyEditor(family); setFamilyLevel(family.level); setFamilyName(family.name);
setFamilyParentId(family.parentFamilyId ?? ''); setFamilyActive(family.isActive !== false);
setFamilyParentIds(new Set(family.parentFamilyIds)); setFamilyActive(family.isActive !== false);
setFamilyFindingIds(new Set(family.findingItemIds ?? [])); setFamilyFindingMode('ASSOCIATED'); setFamilyFindingSearch('');
setError(''); setSuccess('');
setTechnicalEditor(null); setTechnicalLoading(true); setError(''); setSuccess('');
try { setTechnicalAttributes((await getInventoryFamilyAttributes(family.id)).items); }
catch (requestError) { setError(errorMessage(requestError)); setTechnicalAttributes([]); }
finally { setTechnicalLoading(false); }
};
const toggleParentCompatibility = (id: string) => setFamilyParentIds((current) => {
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
});
const toggleFamilyFinding = (id: string) => setFamilyFindingIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
});
const saveFamily = async (event: FormEvent) => {
event.preventDefault(); setSaving(true); setError(''); setSuccess('');
try {
if (familyEditor === 'new') {
await createInventoryFamily({
level: familyLevel,
name: familyName.trim(),
parentFamilyId: familyLevel === 'SUBINSTALLATION' ? familyParentId : null,
const created = await createInventoryFamily({
level: familyLevel, name: familyName.trim(),
parentFamilyIds: familyLevel === 'SUBINSTALLATION' ? [...familyParentIds] : [],
});
setSuccess(`${familyLevel === 'INSTALLATION' ? 'Tipo de Instalación' : 'Tipo de Subinstalación'} creado.`);
} else if (familyEditor) {
await load(); await openFamily(created); return;
}
if (familyEditor) {
await updateInventoryFamily(familyEditor.id, {
name: familyName.trim(),
parentFamilyId: familyLevel === 'SUBINSTALLATION' ? familyParentId : null,
name: familyName.trim(), parentFamilyIds: familyLevel === 'SUBINSTALLATION' ? [...familyParentIds] : [],
isActive: familyActive,
});
await replaceInventoryFamilyFindings(familyEditor.id, {
itemIds: [...familyFindingIds],
reason: 'Actualización desde Configuración de Inventarios',
if (canManageFindings) await replaceInventoryFamilyFindings(familyEditor.id, {
itemIds: [...familyFindingIds], reason: 'Actualización desde Configuración de Inventarios F6',
});
setSuccess('Clasificación y Hallazgos asociados actualizados.');
setSuccess('Clasificación actualizada.'); await load(); setFamilyEditor(null);
}
await load();
setFamilyEditor(null);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const openTechnicalAttribute = (attribute: InventoryFamilyAttribute | 'new') => {
setTechnicalEditor(attribute); setError(''); setSuccess('');
if (attribute === 'new') {
setTechnicalCode(''); setTechnicalName(''); setTechnicalType('TEXT'); setTechnicalRequired(false);
setTechnicalActive(true); setTechnicalUnit(''); setTechnicalOptions(''); setTechnicalOrder(technicalAttributes.length);
} else {
setTechnicalCode(attribute.code); setTechnicalName(attribute.name); setTechnicalType(attribute.dataType);
setTechnicalRequired(attribute.isRequired); setTechnicalActive(attribute.isActive); setTechnicalUnit(attribute.unit ?? '');
setTechnicalOptions(attribute.options?.join('\n') ?? ''); setTechnicalOrder(attribute.sortOrder);
}
};
const saveTechnicalAttribute = async (event: FormEvent) => {
event.preventDefault();
if (!familyEditor || familyEditor === 'new' || !technicalEditor) return;
setSaving(true); setError(''); setSuccess('');
const options = technicalOptions.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
try {
if (technicalEditor === 'new') await createInventoryFamilyAttribute(familyEditor.id, {
code: technicalCode, name: technicalName.trim(), dataType: technicalType, isRequired: technicalRequired,
unit: technicalUnit || null, ...(technicalType === 'SELECT' ? { options } : {}), sortOrder: technicalOrder,
});
else await updateInventoryFamilyAttribute(familyEditor.id,technicalEditor.id,{
name:technicalName.trim(),dataType:technicalType,isRequired:technicalRequired,isActive:technicalActive,
unit:technicalUnit || null,options:technicalType==='SELECT' ? options : null,sortOrder:technicalOrder,
});
setTechnicalAttributes((await getInventoryFamilyAttributes(familyEditor.id)).items);
setTechnicalEditor(null); setSuccess('Campo técnico actualizado.'); await load();
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
@@ -188,100 +231,63 @@ export function AssetTypesPage() {
setAttributeEditor(attribute); setError(''); setSuccess('');
if (attribute === 'new') {
setAttributeCode(''); setAttributeName(''); setAttributeType('TEXT'); setAttributeRequired(false);
setAttributeActive(true); setAttributeUnit(''); setAttributeOptions('');
setAttributeOrder(selectedType?.attributes.length ?? 0);
setAttributeActive(true); setAttributeUnit(''); setAttributeOptions(''); setAttributeOrder(selectedType?.attributes.length ?? 0);
} else {
setAttributeCode(attribute.code); setAttributeName(attribute.name); setAttributeType(attribute.dataType);
setAttributeRequired(attribute.isRequired); setAttributeActive(attribute.isActive);
setAttributeUnit(attribute.unit ?? ''); setAttributeOptions(attribute.options?.join('\n') ?? '');
setAttributeOrder(attribute.sortOrder);
setAttributeRequired(attribute.isRequired); setAttributeActive(attribute.isActive); setAttributeUnit(attribute.unit ?? '');
setAttributeOptions(attribute.options?.join('\n') ?? ''); setAttributeOrder(attribute.sortOrder);
}
};
const saveAttribute = async (event: FormEvent) => {
event.preventDefault();
if (!selectedType || !attributeEditor) return;
event.preventDefault(); if (!selectedType || !attributeEditor) return;
setSaving(true); setError(''); setSuccess('');
const options = attributeOptions.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
try {
if (attributeEditor === 'new') {
await createAssetAttribute(selectedType.id, {
code: attributeCode,
name: attributeName,
dataType: attributeType,
isRequired: attributeRequired,
unit: attributeUnit || null,
...(attributeType === 'SELECT' ? { options } : {}),
sortOrder: attributeOrder,
});
setSuccess(`Nueva columna agregada a ${LEVELS.find((item) => item.kind === selectedKind)?.label}.`);
} else {
await updateAssetAttribute(selectedType.id, attributeEditor.id, {
name: attributeName,
dataType: attributeType,
isRequired: attributeRequired,
isActive: attributeActive,
unit: attributeUnit || null,
options: attributeType === 'SELECT' ? options : null,
sortOrder: attributeOrder,
});
setSuccess('Columna actualizada.');
}
await load();
setAttributeEditor(null);
if (attributeEditor === 'new') await createAssetAttribute(selectedType.id, {
code: attributeCode,name: attributeName,dataType: attributeType,isRequired: attributeRequired,
unit: attributeUnit || null,...(attributeType === 'SELECT' ? { options } : {}),sortOrder: attributeOrder,
});
else await updateAssetAttribute(selectedType.id, attributeEditor.id, {
name: attributeName,dataType: attributeType,isRequired: attributeRequired,isActive: attributeActive,
unit: attributeUnit || null,options: attributeType === 'SELECT' ? options : null,sortOrder: attributeOrder,
});
await load(); setAttributeEditor(null); setSuccess('Campo general actualizado.');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <LoadingBlock label="Cargando configuración de Inventarios…" />;
return <section className="inventory-config-page">
<div className="page-heading">
<div>
<span className="eyebrow">ADMINISTRACIÓN</span>
<h1>Configuración de Inventarios</h1>
<p>Administrá la estructura, los tipos de Instalación/Subinstalación, sus Hallazgos y las columnas de información.</p>
</div>
{canManage && <Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>}
</div>
<div className="page-heading"><div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Configuración de Inventarios</h1><p>Administrá clasificaciones, compatibilidades, Hallazgos y campos técnicos desde un único lugar.</p></div>{canManage && <Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>}</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="panel" style={{ marginBottom: 18 }}>
<div className="panel-heading"><div><span className="eyebrow">MODELO VIGENTE</span><h2>Estructura física</h2><p className="section-copy">Cada nivel tiene un único padre estructural. Empresa queda como maestro independiente y se relaciona con Área.</p></div></div>
<div className="asset-browser-levels" aria-label="Estructura de Inventarios">
<div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i></i>
<div><span>2</span><strong>Área</strong><small>dentro del Departamento</small></div><i></i>
<div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i>
<div><span>4</span><strong>Instalación</strong><small>clasificación técnica</small></div><i></i>
<div><span>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div>
</div>
<div className="temporal-notice" style={{ marginTop: 14 }}><Icon name="users" /><p><strong>Empresa:</strong> maestro independiente. Cambiar la operadora de un Área no mueve ni reescribe la estructura física.</p></div>
</div>
<div className="panel" style={{ marginBottom: 18 }}><div className="panel-heading"><div><span className="eyebrow">MODELO F6</span><h2>Estructura física fija</h2><p className="section-copy">La clasificación describe qué es cada Instalación/Subinstalación, pero nunca altera su nivel jerárquico.</p></div></div><div className="asset-browser-levels" aria-label="Estructura de Inventarios"><div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i></i><div><span>2</span><strong>Área</strong><small>dentro del Departamento</small></div><i></i><div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i><div><span>4</span><strong>Instalación</strong><small>clasificación técnica</small></div><i></i><div><span>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div></div><div className="temporal-notice" style={{ marginTop: 14 }}><Icon name="users" /><p><strong>Empresa:</strong> maestro independiente; su relación con Área no reescribe el árbol físico.</p></div></div>
<div className="dashboard-grid" style={{ alignItems: 'start' }}>
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Instalación</h2><p className="section-copy">Tocá una Instalación para filtrar sus Subinstalaciones y editar sus Hallazgos.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('INSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div>
<div className="attribute-list">{installationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className={`attribute-card ${selectedInstallationFamilyId === family.id ? 'active' : ''}`} key={family.id} onClick={() => openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.findingCount ?? 0} Hallazgos asociados</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>
</article>
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Subinstalación</h2><p className="section-copy">Mostrá todas o sólo las que pertenecen a una Instalación.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('SUBINSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div>
<label className="field" style={{ marginBottom: 14 }}><span>Filtrar por tipo de Instalación</span><SearchableSelect value={selectedInstallationFamilyId} onChange={(event) => setSelectedInstallationFamilyId(event.target.value)}><option value="">Todas las Instalaciones</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}</SearchableSelect></label>
{visibleSubinstallationFamilies.length === 0 ? <div className="inline-empty">{selectedInstallationFamilyId ? 'Esta Instalación todavía no tiene tipos de Subinstalación asociados.' : 'No hay tipos de Subinstalación configurados.'}</div> : <div className="attribute-list" style={{ maxHeight: 560, overflow: 'auto' }}>{visibleSubinstallationFamilies.map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilyName ?? 'Sin Instalación padre'} · {family.findingCount ?? 0} Hallazgos asociados</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>}
</article>
<article className="panel"><div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Instalación</h2><p className="section-copy">Tocá uno para editar Hallazgos, campos técnicos y ver sus Subinstalaciones compatibles.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('INSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div><div className="attribute-list">{installationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className={`attribute-card ${selectedInstallationFamilyId === family.id ? 'active' : ''}`} key={family.id} onClick={() => void openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.findingCount ?? 0} Hallazgos · {family.technicalAttributeCount ?? 0} campos técnicos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div></article>
<article className="panel"><div className="panel-heading"><div><span className="eyebrow">COMPATIBILIDAD</span><h2>Tipos de Subinstalación</h2><p className="section-copy">Una misma clasificación puede ser válida para varias Instalaciones.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('SUBINSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div><label className="field" style={{ marginBottom: 14 }}><span>Filtrar por tipo de Instalación</span><SearchableSelect value={selectedInstallationFamilyId} onChange={(event) => setSelectedInstallationFamilyId(event.target.value)}><option value="">Todas las Instalaciones</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}</SearchableSelect></label>{visibleSubinstallationFamilies.length === 0 ? <div className="inline-empty">{selectedInstallationFamilyId ? 'No hay tipos compatibles con esta Instalación.' : 'No hay tipos de Subinstalación configurados.'}</div> : <div className="attribute-list" style={{ maxHeight: 560, overflow: 'auto' }}>{visibleSubinstallationFamilies.map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => void openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilies.map((parent) => parent.name).join(' · ') || 'Sin compatibilidades'} · {family.findingCount ?? 0} Hallazgos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>}</article>
</div>
<article className="panel" style={{ marginTop: 18 }}>
<div className="panel-heading"><div><span className="eyebrow">COLUMNAS / CAMPOS</span><h2>Información configurable</h2><p className="section-copy">Agregá las columnas que deben completarse para cada nivel. No modifica la jerarquía.</p></div>{canManage && selectedType && <button className="button primary" onClick={() => openAttribute('new')}><Icon name="plus" />Nueva columna</button>}</div>
<div className="quick-view-row" style={{ marginBottom: 16 }}>{LEVELS.map((level) => <button type="button" key={level.kind} className={selectedKind === level.kind ? 'active' : ''} onClick={() => { setSelectedKind(level.kind); setAttributeEditor(null); }}>{level.label}</button>)}</div>
<p className="section-copy">{LEVELS.find((item) => item.kind === selectedKind)?.description}</p>
{!selectedType ? <Alert>Este nivel todavía no tiene un tipo maestro activo.</Alert> : selectedType.attributes.length === 0 ? <div className="inline-empty">No hay columnas adicionales configuradas para {LEVELS.find((item) => item.kind === selectedKind)?.label}.</div> : <div className="attribute-list">{selectedType.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>}
</article>
<article className="panel" style={{ marginTop: 18 }}><div className="panel-heading"><div><span className="eyebrow">CAMPOS GENERALES DEL NIVEL</span><h2>Información estructural</h2><p className="section-copy">Usá estos campos sólo cuando correspondan a todo el nivel. Marca, potencia, capacidad u otros datos técnicos deben configurarse dentro de cada clasificación.</p></div>{canManage && selectedType && <button className="button primary" onClick={() => openAttribute('new')}><Icon name="plus" />Nuevo campo general</button>}</div><div className="quick-view-row" style={{ marginBottom: 16 }}>{LEVELS.map((level) => <button type="button" key={level.kind} className={selectedKind === level.kind ? 'active' : ''} onClick={() => { setSelectedKind(level.kind); setAttributeEditor(null); }}>{level.label}</button>)}</div><p className="section-copy">{LEVELS.find((item) => item.kind === selectedKind)?.description}</p>{!selectedType ? <Alert>Este nivel todavía no tiene un tipo maestro activo.</Alert> : selectedType.attributes.length === 0 ? <div className="inline-empty">No hay campos generales para este nivel.</div> : <div className="attribute-list">{selectedType.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>}</article>
<div className="panel" style={{ marginTop: 18 }}><div className="panel-heading"><div><span className="eyebrow">HALLAZGOS</span><h2>Relación por clasificación</h2><p className="section-copy">Los Hallazgos se pueden revisar y editar directamente dentro de cada tipo. El Catálogo completo sigue disponible para una administración masiva.</p></div><Link className="button secondary" to="/admin/finding-catalog">Abrir Catálogo de hallazgos <Icon name="chevron" /></Link></div></div>
{familyEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveFamily}><div className="drawer-heading"><div><span className="eyebrow">CLASIFICACIÓN TÉCNICA</span><h2>{familyEditor === 'new' ? 'Nueva clasificación' : familyName}</h2></div><button type="button" className="icon-button" onClick={() => setFamilyEditor(null)}>×</button></div><div className="catalog-editor-fields">
{familyEditor === 'new' && <label className="field"><span>Nivel</span><SearchableSelect value={familyLevel} onChange={(event) => { setFamilyLevel(event.target.value as 'INSTALLATION' | 'SUBINSTALLATION'); setFamilyParentIds(new Set()); }}><option value="INSTALLATION">Instalación</option><option value="SUBINSTALLATION">Subinstalación</option></SearchableSelect></label>}
<label className="field"><span>Nombre</span><input value={familyName} onChange={(event) => setFamilyName(event.target.value)} maxLength={240} required /></label>
{familyLevel === 'SUBINSTALLATION' && <div className="field"><span>Tipos de Instalación compatibles <em>{familyParentIds.size} seleccionados</em></span><div className="finding-selection-list" style={{ maxHeight: 220, overflow: 'auto' }}>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label className="finding-selection-row" key={family.id}><input type="checkbox" checked={familyParentIds.has(family.id)} onChange={() => toggleParentCompatibility(family.id)} /><span><strong>{family.name}</strong><small>{family.code}</small></span></label>)}</div></div>}
{familyEditor !== 'new' && <>
<div className="temporal-notice"><Icon name="alert" /><p><strong>{familyFindingIds.size} Hallazgos asociados.</strong> La APK sólo verá estos Hallazgos para esta clasificación, además de OTROS.</p></div>
<div className="quick-view-row"><button type="button" className={familyFindingMode === 'ASSOCIATED' ? 'active' : ''} onClick={() => setFamilyFindingMode('ASSOCIATED')}>Asociados ({familyFindingIds.size})</button>{canManageFindings && <button type="button" className={familyFindingMode === 'ALL' ? 'active' : ''} onClick={() => setFamilyFindingMode('ALL')}>Agregar o quitar</button>}</div>
<label className="field"><span>Buscar Hallazgo</span><input value={familyFindingSearch} onChange={(event) => setFamilyFindingSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>
{visibleFamilyFindings.length === 0 ? <div className="inline-empty">{familyFindingMode === 'ASSOCIATED' ? 'No hay Hallazgos asociados.' : 'No hay coincidencias.'}</div> : <div className="finding-selection-list" style={{ maxHeight: 280, overflow: 'auto' }}>{visibleFamilyFindings.map((item) => familyFindingMode === 'ALL' ? <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={familyFindingIds.has(item.id)} onChange={() => toggleFamilyFinding(item.id)} /><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></label> : <div className="finding-selection-row" key={item.id}><span className="asset-symbol"><Icon name="check" size={14} /></span><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></div>)}</div>}
{familyEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveFamily}><div className="drawer-heading"><div><span className="eyebrow">CLASIFICACIÓN DE INVENTARIO</span><h2>{familyEditor === 'new' ? 'Nuevo tipo' : 'Editar tipo'}</h2></div><button type="button" className="icon-button" onClick={() => setFamilyEditor(null)}>×</button></div><div className="catalog-editor-fields">{familyEditor === 'new' && <label className="field"><span>Nivel</span><SearchableSelect value={familyLevel} onChange={(event) => { setFamilyLevel(event.target.value as 'INSTALLATION' | 'SUBINSTALLATION'); setFamilyParentId(''); }}><option value="INSTALLATION">Instalación</option><option value="SUBINSTALLATION">Subinstalación</option></SearchableSelect></label>}<label className="field"><span>Nombre</span><input value={familyName} onChange={(event) => setFamilyName(event.target.value)} maxLength={240} required placeholder={familyLevel === 'INSTALLATION' ? 'Ej.: Estación, Planta…' : 'Ej.: Tanque, Bomba…'} /></label>{familyLevel === 'SUBINSTALLATION' && <label className="field"><span>Tipo de Instalación padre</span><SearchableSelect value={familyParentId} onChange={(event) => setFamilyParentId(event.target.value)} required><option value="">Seleccionar</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option value={family.id} key={family.id}>{family.name}</option>)}</SearchableSelect></label>}{familyEditor !== 'new' && <><div className="temporal-notice"><Icon name="alert" /><p><strong>{familyFindingIds.size} Hallazgos asociados.</strong> Se guardan junto con esta clasificación.</p></div><div className="quick-view-row"><button type="button" className={familyFindingMode === 'ASSOCIATED' ? 'active' : ''} onClick={() => setFamilyFindingMode('ASSOCIATED')}>Asociados ({familyFindingIds.size})</button><button type="button" className={familyFindingMode === 'ALL' ? 'active' : ''} onClick={() => setFamilyFindingMode('ALL')}>Agregar o quitar</button></div><label className="field"><span>Buscar Hallazgo</span><input value={familyFindingSearch} onChange={(event) => setFamilyFindingSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>{visibleFamilyFindings.length === 0 ? <div className="inline-empty">{familyFindingMode === 'ASSOCIATED' ? 'No hay Hallazgos asociados. Tocá “Agregar o quitar” para vincularlos.' : 'No hay Hallazgos que coincidan con la búsqueda.'}</div> : <div className="finding-selection-list" style={{ maxHeight: 300, overflow: 'auto' }}>{visibleFamilyFindings.map((item) => familyFindingMode === 'ALL' ? <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={familyFindingIds.has(item.id)} onChange={() => toggleFamilyFinding(item.id)} /><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></label> : <div className="finding-selection-row" key={item.id}><span className="asset-symbol"><Icon name="check" size={14} /></span><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></div>)}</div>}<Link className="button secondary" to={`/admin/finding-catalog?familyId=${familyEditor.id}`}>Abrir en Catálogo completo <Icon name="chevron" /></Link><label className="check-row"><input type="checkbox" checked={familyActive} onChange={(event) => setFamilyActive(event.target.checked)} /><span><strong>Tipo disponible</strong><small>Al desactivarlo deja de ofrecerse en nuevas altas.</small></span></label></>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setFamilyEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !familyName.trim() || (familyLevel === 'SUBINSTALLATION' && !familyParentId)}>{saving ? 'Guardando…' : 'Guardar tipo y Hallazgos'}</button></div></form></div>}
<div className="panel-heading" style={{ marginTop: 10 }}><div><span className="eyebrow">DATOS DEL RUBRO</span><h3>Campos técnicos</h3><p className="section-copy">Sólo aparecen cuando un elemento usa esta clasificación.</p></div>{canManage && !technicalEditor && <button type="button" className="button secondary" onClick={() => openTechnicalAttribute('new')}><Icon name="plus" />Nuevo campo</button>}</div>
{technicalLoading ? <LoadingBlock label="Cargando campos técnicos…" /> : technicalAttributes.length === 0 && !technicalEditor ? <div className="inline-empty">No hay campos técnicos definidos.</div> : !technicalEditor && <div className="attribute-list">{technicalAttributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openTechnicalAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span>{attribute.isRequired && <span className="tag">Obligatorio</span>}<Icon name="chevron" /></button>)}</div>}
{technicalEditor && <div className="panel" style={{ padding: 14 }}><div className="panel-heading"><div><strong>{technicalEditor === 'new' ? 'Nuevo campo técnico' : 'Editar campo técnico'}</strong></div><button type="button" className="icon-button" onClick={() => setTechnicalEditor(null)}>×</button></div><label className="field"><span>Nombre</span><input value={technicalName} onChange={(event) => { setTechnicalName(event.target.value); if (technicalEditor === 'new') setTechnicalCode(attributeCodeFromName(event.target.value)); }} required /></label><label className="field"><span>Código interno</span><input value={technicalCode} onChange={(event) => setTechnicalCode(event.target.value.toLowerCase())} disabled={technicalEditor !== 'new'} pattern="[a-z][a-z0-9_]*" required /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={technicalType} onChange={(event) => setTechnicalType(event.target.value as InventoryFamilyAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>{technicalType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={4} value={technicalOptions} onChange={(event) => setTechnicalOptions(event.target.value)} placeholder="Una por línea" required /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={technicalUnit} onChange={(event) => setTechnicalUnit(event.target.value)} /></label><label className="field"><span>Orden</span><input type="number" min={0} max={10000} value={technicalOrder} onChange={(event) => setTechnicalOrder(Number(event.target.value))} /></label><label className="check-row"><input type="checkbox" checked={technicalRequired} onChange={(event) => setTechnicalRequired(event.target.checked)} /><span><strong>Obligatorio</strong><small>Debe completarse para esta clasificación.</small></span></label>{technicalEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={technicalActive} onChange={(event) => setTechnicalActive(event.target.checked)} /><span><strong>Campo activo</strong><small>Desactivarlo conserva datos existentes.</small></span></label>}<div className="form-actions"><button type="button" className="button secondary" onClick={() => setTechnicalEditor(null)}>Cancelar</button><button type="button" className="button primary" disabled={saving || !technicalName.trim() || !technicalCode || (technicalType === 'SELECT' && !technicalOptions.trim())} onClick={(event) => void saveTechnicalAttribute(event as unknown as FormEvent)}>{saving ? 'Guardando…' : 'Guardar campo'}</button></div></div>}
<Link className="button secondary" to={`/admin/finding-catalog?familyId=${familyEditor.id}`}>Abrir Catálogo completo <Icon name="chevron" /></Link>
<label className="check-row"><input type="checkbox" checked={familyActive} onChange={(event) => setFamilyActive(event.target.checked)} /><span><strong>Clasificación disponible</strong><small>Al desactivarla deja de ofrecerse en nuevas altas.</small></span></label>
</>}
</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setFamilyEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !familyName.trim() || (familyLevel === 'SUBINSTALLATION' && familyParentIds.size === 0)}>{saving ? 'Guardando…' : familyEditor === 'new' ? 'Crear clasificación' : 'Guardar clasificación'}</button></div></form></div>}
{attributeEditor && selectedType && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveAttribute}><div className="drawer-heading"><div><span className="eyebrow">COLUMNA DE {LEVELS.find((item) => item.kind === selectedKind)?.label.toUpperCase()}</span><h2>{attributeEditor === 'new' ? 'Nueva columna' : 'Editar columna'}</h2></div><button type="button" className="icon-button" onClick={() => setAttributeEditor(null)}>×</button></div><div className="catalog-editor-fields"><label className="field"><span>Nombre visible</span><input value={attributeName} onChange={(event) => { setAttributeName(event.target.value); if (attributeEditor === 'new') setAttributeCode(attributeCodeFromName(event.target.value)); }} maxLength={160} required /></label><label className="field"><span>Código interno</span><input value={attributeCode} onChange={(event) => setAttributeCode(event.target.value.toLowerCase())} disabled={attributeEditor !== 'new'} maxLength={80} required pattern="[a-z][a-z0-9_]*" /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={attributeType} onChange={(event) => setAttributeType(event.target.value as AssetAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}</SearchableSelect></label>{attributeType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={5} value={attributeOptions} onChange={(event) => setAttributeOptions(event.target.value)} placeholder="Una opción por línea" /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={attributeUnit} onChange={(event) => setAttributeUnit(event.target.value)} maxLength={40} /></label><label className="field"><span>Orden</span><input type="number" value={attributeOrder} onChange={(event) => setAttributeOrder(Number(event.target.value))} min={0} max={10000} /></label><label className="check-row"><input type="checkbox" checked={attributeRequired} onChange={(event) => setAttributeRequired(event.target.checked)} /><span><strong>Campo obligatorio</strong><small>Debe completarse cuando se registra este nivel.</small></span></label>{attributeEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={attributeActive} onChange={(event) => setAttributeActive(event.target.checked)} /><span><strong>Columna activa</strong><small>Desactivarla conserva los datos actuales.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setAttributeEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !attributeName.trim() || !attributeCode}>{saving ? 'Guardando…' : 'Guardar columna'}</button></div></form></div>}
{attributeEditor && selectedType && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveAttribute}><div className="drawer-heading"><div><span className="eyebrow">CAMPO GENERAL DE {LEVELS.find((item) => item.kind === selectedKind)?.label.toUpperCase()}</span><h2>{attributeEditor === 'new' ? 'Nuevo campo' : 'Editar campo'}</h2></div><button type="button" className="icon-button" onClick={() => setAttributeEditor(null)}>×</button></div><div className="catalog-editor-fields"><label className="field"><span>Nombre visible</span><input value={attributeName} onChange={(event) => { setAttributeName(event.target.value); if (attributeEditor === 'new') setAttributeCode(attributeCodeFromName(event.target.value)); }} maxLength={160} required /></label><label className="field"><span>Código interno</span><input value={attributeCode} onChange={(event) => setAttributeCode(event.target.value.toLowerCase())} disabled={attributeEditor !== 'new'} maxLength={80} required pattern="[a-z][a-z0-9_]*" /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={attributeType} onChange={(event) => setAttributeType(event.target.value as AssetAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}</SearchableSelect></label>{attributeType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={5} value={attributeOptions} onChange={(event) => setAttributeOptions(event.target.value)} placeholder="Una opción por línea" /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={attributeUnit} onChange={(event) => setAttributeUnit(event.target.value)} maxLength={40} /></label><label className="field"><span>Orden</span><input type="number" value={attributeOrder} onChange={(event) => setAttributeOrder(Number(event.target.value))} min={0} max={10000} /></label><label className="check-row"><input type="checkbox" checked={attributeRequired} onChange={(event) => setAttributeRequired(event.target.checked)} /><span><strong>Campo obligatorio</strong><small>Debe completarse cuando se registra este nivel.</small></span></label>{attributeEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={attributeActive} onChange={(event) => setAttributeActive(event.target.checked)} /><span><strong>Campo activo</strong><small>Desactivarlo conserva los datos actuales.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setAttributeEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !attributeName.trim() || !attributeCode}>{saving ? 'Guardando…' : 'Guardar campo'}</button></div></form></div>}
</section>;
}
+21 -70
View File
@@ -26,24 +26,13 @@ const KINDS: Array<{ kind: InventoryStructureKind; label: string }> = [
{ kind: 'SUBINSTALACION', label: 'Subinstalación' },
{ kind: 'EMPRESA', label: 'Empresa' },
];
const childKindByParentType: Record<string, InventoryStructureKind | undefined> = {
departamento: 'AREA',
area: 'YACIMIENTO',
yacimiento: 'INSTALACION',
instalacion: 'SUBINSTALACION',
departamento: 'AREA', area: 'YACIMIENTO', yacimiento: 'INSTALACION', instalacion: 'SUBINSTALACION',
};
const parentLabelByKind: Partial<Record<InventoryStructureKind,string>> = {
AREA:'Departamento',
YACIMIENTO:'Área',
INSTALACION:'Yacimiento',
SUBINSTALACION:'Instalación',
AREA:'Departamento', YACIMIENTO:'Área', INSTALACION:'Yacimiento', SUBINSTALACION:'Instalación',
};
function kindLabel(kind: InventoryStructureKind) {
return KINDS.find((item) => item.kind===kind)?.label ?? kind;
}
function kindLabel(kind: InventoryStructureKind) { return KINDS.find((item) => item.kind===kind)?.label ?? kind; }
export function InventoryCreatePage() {
const navigate = useNavigate();
@@ -65,17 +54,14 @@ export function InventoryCreatePage() {
const [error, setError] = useState('');
useEffect(() => {
getInventoryStructureOptions()
.then(async (loaded) => {
setOptions(loaded);
if (contextParentId) {
const parent = await getAsset(contextParentId);
const inferred = childKindByParentType[parent.type.code.toLowerCase()];
if (inferred) { setKind(inferred); setParentId(parent.id); }
}
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
getInventoryStructureOptions().then(async (loaded) => {
setOptions(loaded);
if (contextParentId) {
const parent = await getAsset(contextParentId);
const inferred = childKindByParentType[parent.type.code.toLowerCase()];
if (inferred) { setKind(inferred); setParentId(parent.id); }
}
}).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
}, [contextParentId]);
const requiresParent = Boolean(parentLabelByKind[kind]);
@@ -85,12 +71,10 @@ export function InventoryCreatePage() {
useEffect(() => {
if (!requiresParent) { setParents([]); setParentId(''); return; }
const timer = window.setTimeout(() => {
listInventoryStructureParents(kind,parentSearch)
.then((loaded) => {
setParents(loaded);
if (contextParentId && loaded.some((item) => item.id===contextParentId)) setParentId(contextParentId);
})
.catch((requestError) => setError(errorMessage(requestError)));
listInventoryStructureParents(kind,parentSearch).then((loaded) => {
setParents(loaded);
if (contextParentId && loaded.some((item) => item.id===contextParentId)) setParentId(contextParentId);
}).catch((requestError) => setError(errorMessage(requestError)));
},150);
return () => window.clearTimeout(timer);
}, [kind,parentSearch,contextParentId,requiresParent]);
@@ -101,7 +85,7 @@ export function InventoryCreatePage() {
if (kind==='INSTALACION') return options.installationFamilies;
if (kind==='SUBINSTALACION') {
const parentFamilyId=selectedParent?.inventoryFamily?.id;
return parentFamilyId ? options.subinstallationFamilies.filter((item)=>item.parentFamilyId===parentFamilyId) : [];
return parentFamilyId ? options.subinstallationFamilies.filter((item)=>item.parentFamilyIds.includes(parentFamilyId)) : [];
}
return [];
},[options,kind,selectedParent]);
@@ -111,7 +95,6 @@ export function InventoryCreatePage() {
if (!familyId) { setFamilyFindings(null); return; }
getInventoryFamilyFindings(familyId).then(setFamilyFindings).catch((requestError)=>setError(errorMessage(requestError)));
},[familyId]);
useEffect(() => {
if (!requiresFamily) setFamilyId('');
if (kind==='SUBINSTALACION' && familyId && !families.some((item)=>item.id===familyId)) setFamilyId('');
@@ -120,7 +103,6 @@ export function InventoryCreatePage() {
const changeKind=(next:InventoryStructureKind) => {
setKind(next); setParentId(''); setParentSearch(''); setFamilyId(''); setFamilyFindings(null); setError('');
};
const save=async(event:FormEvent) => {
event.preventDefault();
if (requiresParent && !parentId) { setError(`Seleccioná ${parentLabel}.`); return; }
@@ -137,46 +119,15 @@ export function InventoryCreatePage() {
};
if (loading) return <LoadingBlock label="Preparando alta…" />;
return <section className="narrow-section asset-detail-page">
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del Inventario"><Link to="/inventarios">Inventarios</Link><span></span><strong>Nuevo registro</strong></nav>
<div className="page-heading asset-editor-heading">
<div><span className="eyebrow">CARGA MANUAL</span><h1>Nuevo registro</h1><p>Elegí qué querés crear, dónde va y su nombre. El resto es opcional.</p></div>
</div>
<div className="page-heading asset-editor-heading"><div><span className="eyebrow">CARGA MANUAL</span><h1>Nuevo registro</h1><p>Para crear sólo necesitás ubicación, clasificación cuando corresponda y nombre. DH genera el código automáticamente.</p></div></div>
{error && <Alert>{error}</Alert>}
<form className="panel form-panel" onSubmit={save}>
<div className="form-section">
<div><h2>1. Tipo de registro</h2><p className="section-copy">La estructura física es Departamento Área Yacimiento Instalación Subinstalación. Empresa es un maestro independiente.</p></div>
<label className="field"><span>Crear</span><select value={kind} onChange={(event)=>changeKind(event.target.value as InventoryStructureKind)}>{KINDS.map((item)=><option key={item.kind} value={item.kind}>{item.label}</option>)}</select></label>
</div>
{requiresParent && <div className="form-section">
<div><h2>2. Ubicación</h2><p className="section-copy">Elegí el {parentLabel.toLowerCase()} al que pertenece.</p></div>
<label className="field"><span>Buscar {parentLabel.toLowerCase()}</span><input value={parentSearch} onChange={(event)=>setParentSearch(event.target.value)} placeholder={`Buscar ${parentLabel.toLowerCase()}`} /></label>
<label className="field"><span>{parentLabel} <em>obligatorio</em></span><select value={parentId} onChange={(event)=>{setParentId(event.target.value);setFamilyId('');}} required><option value="">Seleccionar</option>{parents.map((parent)=><option key={parent.id} value={parent.id}>{parent.name} · {parent.code}</option>)}</select></label>
</div>}
{requiresFamily && <div className="form-section">
<div><h2>{requiresParent ? '3' : '2'}. Clasificación</h2><p className="section-copy">Define qué tipo de elemento es y qué Hallazgos le corresponden.</p></div>
{kind==='SUBINSTALACION' && !selectedParent?.inventoryFamily
? <Alert>La Instalación elegida todavía no tiene clasificación. Asignala antes de crear una Subinstalación.</Alert>
: <label className="field"><span>Tipo de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span><select value={familyId} onChange={(event)=>setFamilyId(event.target.value)} required><option value="">Seleccionar</option>{families.map((family)=><option key={family.id} value={family.id}>{family.name}</option>)}</select></label>}
{selectedFamily && <div className="context-create-banner"><Icon name="alert" /><div><strong>{selectedFamily.name}</strong><span>{familyFindings ? `${familyFindings.count} Hallazgo${familyFindings.count===1?'':'s'} asociado${familyFindings.count===1?'':'s'}` : 'Cargando Hallazgos asociados…'}</span>{familyFindings && familyFindings.items.length>0 && <ul style={{margin:'8px 0 0',paddingLeft:18}}>{familyFindings.items.slice(0,6).map((item)=><li key={item.id}>{item.title}</li>)}{familyFindings.items.length>6 && <li>+ {familyFindings.items.length-6} más</li>}</ul>}</div></div>}
</div>}
<div className="form-section">
<div><h2>{requiresFamily ? '4' : requiresParent ? '3' : '2'}. Identificación</h2><p className="section-copy">Para comenzar sólo necesitamos un nombre claro.</p></div>
<label className="field"><span>Nombre <em>obligatorio</em></span><input autoFocus value={name} onChange={(event)=>setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} /></label>
<details style={{marginTop:12}}><summary style={{cursor:'pointer',fontWeight:700}}>Datos opcionales</summary><div style={{marginTop:14}}>
<div className="form-grid">
<label className="field"><span>Código DH</span><input value={code} onChange={(event)=>setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Se genera solo si lo dejás vacío" /></label>
<label className="field"><span>Nombre habitual</span><input value={commonName} onChange={(event)=>setCommonName(event.target.value)} maxLength={200} /></label>
</div>
<label className="field"><span>Descripción</span><textarea value={description} onChange={(event)=>setDescription(event.target.value)} rows={2} maxLength={4000} /></label>
</div></details>
</div>
<div className="form-section"><div><h2>1. ¿Qué querés crear?</h2><p className="section-copy">Departamento Área Yacimiento Instalación Subinstalación. Empresa queda fuera del árbol físico.</p></div><label className="field"><span>Tipo</span><select value={kind} onChange={(event)=>changeKind(event.target.value as InventoryStructureKind)}>{KINDS.map((item)=><option key={item.kind} value={item.kind}>{item.label}</option>)}</select></label></div>
{requiresParent && <div className="form-section"><div><h2>2. Ubicación</h2><p className="section-copy">Elegí el {parentLabel.toLowerCase()} concreto.</p></div><label className="field"><span>Buscar {parentLabel.toLowerCase()}</span><input value={parentSearch} onChange={(event)=>setParentSearch(event.target.value)} placeholder={`Buscar ${parentLabel.toLowerCase()}`} /></label><label className="field"><span>{parentLabel} <em>obligatorio</em></span><select value={parentId} onChange={(event)=>{setParentId(event.target.value);setFamilyId('');}} required><option value="">Seleccionar</option>{parents.map((parent)=><option key={parent.id} value={parent.id}>{parent.name} · {parent.code}</option>)}</select></label></div>}
{requiresFamily && <div className="form-section"><div><h2>3. Clasificación técnica</h2><p className="section-copy">Define los datos técnicos y Hallazgos aplicables. En Subinstalaciones sólo aparecen clasificaciones compatibles con la Instalación elegida.</p></div>{kind==='SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación elegida todavía no tiene clasificación técnica.</Alert> : <label className="field"><span>Tipo de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span><select value={familyId} onChange={(event)=>setFamilyId(event.target.value)} required><option value="">Seleccionar</option>{families.map((family)=><option key={family.id} value={family.id}>{family.name}</option>)}</select></label>}{kind==='SUBINSTALACION' && selectedParent?.inventoryFamily && families.length===0 && <Alert>No hay tipos de Subinstalación compatibles con {selectedParent.inventoryFamily.name}. Configuralos primero en Configuración de Inventarios.</Alert>}{selectedFamily && <div className="context-create-banner"><Icon name="alert" /><div><strong>{selectedFamily.name}</strong><span>{familyFindings ? `${familyFindings.count} Hallazgo${familyFindings.count===1?'':'s'} asociado${familyFindings.count===1?'':'s'}` : 'Cargando Hallazgos asociados…'}</span>{familyFindings && familyFindings.items.length>0 && <ul style={{margin:'8px 0 0',paddingLeft:18}}>{familyFindings.items.slice(0,6).map((item)=><li key={item.id}>{item.title}</li>)}{familyFindings.items.length>6 && <li>+ {familyFindings.items.length-6} más</li>}</ul>}</div></div>}</div>}
<div className="form-section"><div><h2>{requiresFamily ? '4' : requiresParent ? '3' : '2'}. Nombre</h2><p className="section-copy">Los datos técnicos se completan después, en la ficha del elemento.</p></div><label className="field"><span>Nombre <em>obligatorio</em></span><input autoFocus value={name} onChange={(event)=>setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} /></label><details style={{marginTop:12}}><summary style={{cursor:'pointer',fontWeight:700}}>Datos opcionales</summary><div style={{marginTop:14}}><div className="form-grid"><label className="field"><span>Código DH</span><input value={code} onChange={(event)=>setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Dejalo vacío para generar automáticamente" /></label><label className="field"><span>Nombre habitual</span><input value={commonName} onChange={(event)=>setCommonName(event.target.value)} maxLength={200} /></label></div><label className="field"><span>Descripción</span><textarea value={description} onChange={(event)=>setDescription(event.target.value)} rows={2} maxLength={4000} /></label></div></details></div>
<div className="form-actions"><Link className="button secondary" to="/inventarios">Cancelar</Link><button className="button primary" disabled={saving || !name.trim() || (requiresParent && !parentId) || (requiresFamily && !familyId)}><Icon name="check" />{saving?'Creando…':`Crear ${kindLabel(kind)}`}</button></div>
</form>
</section>;