Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
433220ee3a | ||
|
|
ba38dc7dda | ||
|
|
51ace362d7 | ||
|
|
3a149691e2 | ||
|
|
294abc4cf3 | ||
|
|
90443b3961 | ||
|
|
60c6d97f1a | ||
|
|
ae7326f20a | ||
|
|
5a9c4ed5ef | ||
|
|
0959e85487 | ||
|
|
a4fad611d4 | ||
|
|
d28186a567 | ||
|
|
25a63cd3d4 | ||
|
|
3d8ace5529 | ||
|
|
47203ff0da | ||
|
|
b701becab7 | ||
|
|
9983201de7 | ||
|
|
4fc05b73b1 | ||
|
|
40cfeff42e | ||
|
|
e3f89f4d5a | ||
|
|
7284c252ff | ||
|
|
7baf306a23 | ||
|
|
27a51c2fe2 | ||
|
|
b326eb737d | ||
|
|
6a5db887c0 | ||
|
|
baf63a861c | ||
|
|
d61e4ad04d | ||
|
|
37885c0454 | ||
|
|
b6707cef43 | ||
|
|
74fc08bf24 | ||
|
|
ad7ec2ca2b | ||
|
|
d938db7913 | ||
|
|
0f4bb13fc7 | ||
|
|
f0db23bc9b | ||
|
|
0b0851d9ec | ||
|
|
7338f277ad |
@@ -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
|
||||
@@ -2,9 +2,28 @@
|
||||
|
||||
Repositorio del sistema DH Inspección V2.
|
||||
|
||||
## Estructura
|
||||
## Componentes
|
||||
|
||||
- `api-v3/`: API v3.
|
||||
- `web-v2/`: aplicación web.
|
||||
- `scripts/`: utilidades operativas y de despliegue.
|
||||
- `docs/`: documentación técnica.
|
||||
- `api-v3/`: API NestJS/TypeORM y contratos de dominio.
|
||||
- `web-v2/`: aplicación WEB React/Vite.
|
||||
- `android-app/`: aplicación Android para trabajo de campo.
|
||||
- `scripts/`: utilidades operativas, preflight y despliegue.
|
||||
- `docs/`: documentación funcional y técnica.
|
||||
|
||||
## Documentación vigente
|
||||
|
||||
- [Manual del Programador](docs/MANUAL_PROGRAMADOR.md)
|
||||
- [Manual de Usuario](docs/MANUAL_USUARIO.md)
|
||||
- [Modelo canónico de Inventarios F6.1](docs/F6_INVENTORY_MODEL.md)
|
||||
- [Auditoría de consistencia F6.1](docs/AUDITORIA_CONSISTENCIA_F6_1.md)
|
||||
|
||||
## Contratos que no deben romperse
|
||||
|
||||
- Inventario físico: **Departamento → Área → Yacimiento → Instalación → Subinstalación**.
|
||||
- Empresa es un maestro independiente; la Operadora se relaciona temporalmente con Área.
|
||||
- Una Inspección puede tener múltiples Actas, pero sólo un Acta `DRAFT` simultánea.
|
||||
- Hallazgos directos: Yacimiento, Instalación y Subinstalación, siempre con `OTROS` disponible.
|
||||
- La APK puede abrir una Inspección reutilizando el lifecycle canónico del backend.
|
||||
- GEDO/IF oficializa el Informe, pero no activa por sí solo el vencimiento No urgente.
|
||||
|
||||
Las reglas detalladas y el procedimiento de cambio seguro están en los manuales enlazados arriba.
|
||||
|
||||
@@ -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)
|
||||
|
||||
+103
@@ -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
@@ -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,
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
// Invariante de dominio: Empresa es un maestro independiente. La jerarquía física
|
||||
// se expresa sólo como Departamento → Área → Yacimiento → Instalación → Subinstalación.
|
||||
export const INVENTORY_STRUCTURE_KINDS = [
|
||||
'EMPRESA',
|
||||
'DEPARTAMENTO',
|
||||
|
||||
@@ -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}`});
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ export enum InspectionDeadlineBasis {
|
||||
GEDO_DATE = 'GEDO_DATE',
|
||||
}
|
||||
|
||||
// Una Inspección puede acumular múltiples Actas. El índice parcial de DRAFT es
|
||||
// deliberado: permite el historial multi-Acta y bloquea dos borradores simultáneos.
|
||||
@Entity({ name: 'inspection_acts' })
|
||||
@Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" })
|
||||
@Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -295,11 +295,13 @@ export class InspectionVisitLifecycleService {
|
||||
}
|
||||
}
|
||||
|
||||
// El contexto operativo de una Inspección es Área + Operadora vigente. El
|
||||
// Yacimiento pertenece al alcance físico y nunca reemplaza al Área raíz.
|
||||
private async validatePlanningContext(manager: EntityManager, visit: InspectionVisit): Promise<void> {
|
||||
if (!visit.operationalAreaId || !visit.operatorCompanyId || !visit.leadInspectorUserId || !visit.plannedStartAt) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_PLANNING_INCOMPLETE',
|
||||
message: 'Definí Área/Yacimiento, Operadora, Inspector responsable y fecha de inicio antes de planificar',
|
||||
message: 'Definí Área, Operadora, Inspector responsable y fecha de inicio antes de planificar',
|
||||
});
|
||||
}
|
||||
if (!visit.checklistGeneratedAt || visit.checklistGeneration < 1) {
|
||||
@@ -320,7 +322,7 @@ export class InspectionVisitLifecycleService {
|
||||
if (!relation) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_OPERATOR_RELATION_INVALID',
|
||||
message: 'La Operadora seleccionada no tiene una relación operativa vigente con el Área/Yacimiento',
|
||||
message: 'La Operadora seleccionada no tiene una relación operativa vigente con el Área',
|
||||
});
|
||||
}
|
||||
const [inspector] = await manager.query(`
|
||||
|
||||
@@ -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,43 @@ 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);
|
||||
// Android no tiene un lifecycle paralelo: crea la visita y delega las
|
||||
// transiciones DRAFT → PLANNED → IN_PROGRESS a los servicios canónicos.
|
||||
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) {
|
||||
|
||||
@@ -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';
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,71 @@
|
||||
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): string {
|
||||
return readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||
}
|
||||
|
||||
test('F6.1 conserva una jerarquía territorial única y Empresa fuera del árbol físico', () => {
|
||||
const structure = source('src/asset-master/inventory-structure.service.ts');
|
||||
const dto = source('src/asset-master/dto/create-inventory-structure.dto.ts');
|
||||
|
||||
assert.match(dto, /'EMPRESA',[\s\S]*'DEPARTAMENTO',[\s\S]*'AREA',[\s\S]*'YACIMIENTO',[\s\S]*'INSTALACION',[\s\S]*'SUBINSTALACION'/);
|
||||
assert.match(structure, /EMPRESA:\s*null/);
|
||||
assert.match(structure, /DEPARTAMENTO:\s*null/);
|
||||
assert.match(structure, /AREA:\s*'departamento'/);
|
||||
assert.match(structure, /YACIMIENTO:\s*'area'/);
|
||||
assert.match(structure, /INSTALACION:\s*'yacimiento'/);
|
||||
assert.match(structure, /SUBINSTALACION:\s*'instalacion'/);
|
||||
assert.match(structure, /Departamento → Área → Yacimiento → Instalación → Subinstalación/);
|
||||
});
|
||||
|
||||
test('F6.1 usa Área + Operadora como contexto de planificación y no mezcla Yacimiento con Área', () => {
|
||||
const lifecycle = source('src/inspection-visits/inspection-visit-lifecycle.service.ts');
|
||||
const visits = source('src/inspection-visits/inspection-visits.service.ts');
|
||||
|
||||
assert.match(lifecycle, /El contexto operativo de una Inspección es Área \+ Operadora vigente/);
|
||||
assert.doesNotMatch(lifecycle, /Área\/Yacimiento/);
|
||||
assert.match(visits, /FROM area_company_relations/);
|
||||
assert.match(visits, /relation\.relation_role = 'OPERATOR'/);
|
||||
});
|
||||
|
||||
test('F6.1 mantiene múltiples Actas por Inspección pero un solo borrador simultáneo', () => {
|
||||
const act = source('src/database/entities/inspection-act.entity.ts');
|
||||
const lifecycle = source('src/inspection-visits/inspection-visit-lifecycle.service.ts');
|
||||
|
||||
assert.match(act, /uq_inspection_acts_one_draft_per_visit/);
|
||||
assert.match(act, /where: "status = 'DRAFT'"/);
|
||||
assert.match(act, /Una Inspección puede acumular múltiples Actas/);
|
||||
assert.match(lifecycle, /Todas las Actas deben estar SELLADAS antes de cerrar la Inspección/);
|
||||
});
|
||||
|
||||
test('F6.1 permite Hallazgos directos sólo en Yacimiento, Instalación y Subinstalación y conserva OTROS', () => {
|
||||
const field = source('src/inspection-visits/field-findings.service.ts');
|
||||
const resolver = source('src/inspection-findings/f3-finding-catalog-resolver.service.ts');
|
||||
|
||||
assert.match(field, /\['yacimiento', 'instalacion', 'subinstalacion'\]\.includes\(assetTypeCode\)/);
|
||||
assert.match(field, /assetTypeCode !== 'yacimiento' && !row\.inventoryFamilyId/);
|
||||
assert.match(field, /canAddAnother: true/);
|
||||
assert.match(resolver, /code: 'OTHER'/);
|
||||
assert.match(resolver, /label: 'OTROS'/);
|
||||
});
|
||||
|
||||
test('F6.1 abre Inspecciones desde Android usando el lifecycle canónico', () => {
|
||||
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
|
||||
|
||||
assert.match(controller, /@Post\('mobile\/open'\)/);
|
||||
assert.match(controller, /leadInspectorUserId: principal\.userId/);
|
||||
assert.match(controller, /this\.lifecycle\.plan\(created\.id/);
|
||||
assert.match(controller, /this\.lifecycle\.start\(created\.id/);
|
||||
});
|
||||
|
||||
test('F6.1 separa oficialización GEDO de la activación del vencimiento no urgente', () => {
|
||||
const report = source('src/inspection-reports/inspection-report-workflow.service.ts');
|
||||
const act = source('src/database/entities/inspection-act.entity.ts');
|
||||
|
||||
assert.match(report, /GEDO oficializa el INF, pero no equivale por sí solo a la notificación/);
|
||||
assert.match(report, /deadlineActivation: 'PENDING_NOTIFICATION_EVENT'/);
|
||||
assert.match(act, /NOTIFICATION_DATE = 'GEDO_DATE'/);
|
||||
});
|
||||
@@ -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\)/);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
# Auditoría de consistencia · F6.1
|
||||
|
||||
**Baseline revisado:** `25a63cd3d4b2c561803cc5105cce100983c8eeff`
|
||||
**Alcance:** API, modelo de datos, Inventarios, Inspecciones, Actas, Hallazgos, Informes/GEDO, WEB, Android, permisos, versionado documental y CI.
|
||||
|
||||
## Resultado
|
||||
|
||||
El modelo funcional consolidado de F6.1 es coherente. No se detectó una contradicción estructural que requiera rediseñar tablas o migrar datos antes de continuar.
|
||||
|
||||
La auditoría sí encontró deuda de terminología/documentación y una inconsistencia de metadata generada. Las correcciones funcionales/documentales se integran en esta rama; la metadata de lockfiles se documenta como observación no funcional y debe regenerarse con npm en un corte controlado, no editarse parcialmente a mano.
|
||||
|
||||
## Contratos verificados
|
||||
|
||||
### Inventario
|
||||
|
||||
- Jerarquía física única: Departamento → Área → Yacimiento → Instalación → Subinstalación.
|
||||
- Empresa es un maestro independiente.
|
||||
- Operadora vigente se resuelve por relación temporal Área ⇄ Empresa.
|
||||
- Cambiar Operadora no reparenta Inventario.
|
||||
- Departamento, Área y Yacimiento no llevan familia técnica.
|
||||
- Instalación y Subinstalación requieren familia técnica.
|
||||
- Subinstalación debe ser compatible con la familia de la Instalación padre.
|
||||
- PostgreSQL protege jerarquía y compatibilidad, además de las validaciones de API.
|
||||
|
||||
### Inspecciones
|
||||
|
||||
- Lifecycle canónico DRAFT → PLANNED → IN_PROGRESS → CLOSED.
|
||||
- Planificación requiere Área, Operadora vigente, Inspector responsable, fecha/hora y checklist.
|
||||
- La APK abre una Inspección reutilizando el mismo create/plan/start; no existe un lifecycle móvil paralelo.
|
||||
- No hay referencias activas a `plannedEndAt` / `planned_end_at` en el código actual.
|
||||
|
||||
### Actas
|
||||
|
||||
- Una Inspección puede contener múltiples Actas.
|
||||
- El constraint histórico de una sola Acta por visita fue eliminado por F1.1.
|
||||
- Existe un índice parcial que permite sólo un Acta DRAFT simultánea por Inspección.
|
||||
- El cierre de la Inspección exige que todas las Actas no canceladas estén SELLADAS.
|
||||
- El Acta finalizada/bloqueada es inmutable.
|
||||
|
||||
### Hallazgos
|
||||
|
||||
- Destinos directos: Yacimiento, Instalación y Subinstalación.
|
||||
- Departamento y Área no son destinos directos.
|
||||
- OTROS / Agregar otro permanece disponible.
|
||||
- Yacimiento no requiere familia técnica.
|
||||
- Instalación/Subinstalación sí requieren clasificación.
|
||||
- Inventario nacido en campo necesita GPS + foto antes de admitir Hallazgos.
|
||||
|
||||
### Informes / GEDO
|
||||
|
||||
- El Informe pertenece al Acta.
|
||||
- La oficialización GEDO/IF es inmutable.
|
||||
- Oficializar en GEDO no activa por sí solo el vencimiento No urgente.
|
||||
- El valor persistido histórico `GEDO_DATE` se conserva como ABI de base; en código existe el alias semántico `NOTIFICATION_DATE` para evitar interpretar GEDO como notificación automática.
|
||||
|
||||
## Correcciones integradas en la auditoría
|
||||
|
||||
1. Se corrigió la terminología residual `Área/Yacimiento` en el lifecycle de Inspecciones. El contexto operativo queda definido inequívocamente como **Área + Operadora vigente**.
|
||||
2. Se documentó en código la separación entre Empresa y la jerarquía física del Inventario.
|
||||
3. Se documentó en la entidad Acta el contrato multi-Acta + único DRAFT simultáneo.
|
||||
4. Se agregó un test transversal F6.1 que protege jerarquía, contexto operativo, multi-Acta, Hallazgos, apertura móvil y separación GEDO/vencimiento.
|
||||
5. Se consolidó `docs/F6_INVENTORY_MODEL.md` como única referencia viva y los duplicados históricos apuntan a ella.
|
||||
6. Se crearon `MANUAL_PROGRAMADOR.md` y `MANUAL_USUARIO.md`.
|
||||
|
||||
## Compatibilidades históricas que NO deben “limpiarse” sin migración
|
||||
|
||||
- Migraciones antiguas conservan nombres y reglas que fueron válidas en fases anteriores. No se modifican; migraciones posteriores son las que expresan la evolución.
|
||||
- `InspectionDeadlineBasis.GEDO_DATE` comparte valor persistido con `NOTIFICATION_DATE` por compatibilidad de esquema.
|
||||
- `assets.operator_company_id` puede conservar una fotografía de compatibilidad incluso cuando la relación temporal ya finalizó. La fuente de verdad vigente/histórica sigue siendo `area_company_relations`.
|
||||
- F5.1 es una migración destructiva de inicio limpio y no tiene `down()` reconstructivo; su rollback es el backup PRE.
|
||||
|
||||
## Observación no funcional: metadata de package-lock
|
||||
|
||||
Los `package.json` actuales declaran las versiones de producto vigentes, pero la metadata `name/version` de la raíz de ambos `package-lock.json` todavía conserva `0.20.0-2` de un corte anterior.
|
||||
|
||||
Esto no cambia el grafo de dependencias ni el runtime y `npm ci` continúa siendo la barrera de instalación. Se deja explícitamente registrado para no ocultarlo.
|
||||
|
||||
**Tratamiento correcto:** regenerar cada lockfile con npm (`npm install --package-lock-only`) cuando se haga el próximo corte controlado de metadata/dependencias y verificar el diff completo. No editar manualmente sólo las primeras líneas del lockfile.
|
||||
|
||||
## Criterio de aceptación de esta auditoría
|
||||
|
||||
Antes de mergear esta rama deben pasar nuevamente:
|
||||
|
||||
- API typecheck + tests + build.
|
||||
- WEB typecheck + contrato + build.
|
||||
- migraciones sobre PostGIS limpio.
|
||||
- preflight aislado equivalente al VPS.
|
||||
- build de imágenes productivas.
|
||||
- Android assemble + unit tests cuando el workflow se dispare por el PR.
|
||||
|
||||
La rama de documentación/consistencia no debe promoverse a `deploy` durante esta revisión. El despliegue queda deliberadamente pausado.
|
||||
@@ -0,0 +1,131 @@
|
||||
# F6.1 · Modelo canónico de Inventarios
|
||||
|
||||
Este documento es la referencia funcional y técnica del modelo de Inventarios de DH Inspección V2. Ante una contradicción con documentación histórica, prevalecen este documento, las migraciones vigentes y los contratos automatizados de F6.1.
|
||||
|
||||
## 1. Jerarquía física
|
||||
|
||||
La estructura territorial y física es única:
|
||||
|
||||
**Departamento → Área → Yacimiento → Instalación → Subinstalación**
|
||||
|
||||
Reglas:
|
||||
|
||||
- `Departamento` es raíz.
|
||||
- `Área` requiere un Departamento padre.
|
||||
- `Yacimiento` requiere un Área padre.
|
||||
- `Instalación` requiere un Yacimiento padre.
|
||||
- `Subinstalación` requiere una Instalación padre.
|
||||
- La base de datos rechaza combinaciones de padre/hijo que no respeten esta secuencia.
|
||||
- El campo `parent_id` expresa exclusivamente esta pertenencia física.
|
||||
|
||||
## 2. Empresa no pertenece al árbol físico
|
||||
|
||||
`Empresa` es un maestro independiente. No es padre de Departamento, Área, Yacimiento, Instalación ni Subinstalación.
|
||||
|
||||
La Operadora vigente de un Área se expresa mediante `area_company_relations`, con `relation_role = 'OPERATOR'` y vigencia temporal. Esta relación es la fuente de verdad para saber qué Empresa opera un Área en una fecha determinada.
|
||||
|
||||
`assets.operator_company_id` puede existir como fotografía de compatibilidad del contexto actual, pero no debe utilizarse para reconstruir la historia ni para reparentar Inventario.
|
||||
|
||||
**Cambiar de Operadora nunca mueve la jerarquía física.** Se finaliza la relación temporal anterior y se crea la nueva relación Empresa ⇄ Área.
|
||||
|
||||
## 3. Contexto transversal de Área
|
||||
|
||||
Los elementos bajo un Área conservan `operational_area_id` como contexto transversal para consultas, planificación y trazabilidad. El Yacimiento forma parte del alcance físico, pero no sustituye al Área como contexto operativo de una Inspección.
|
||||
|
||||
Por lo tanto, la planificación de una Inspección trabaja con:
|
||||
|
||||
**Área + Operadora vigente + Inspector responsable + fecha/hora de inicio**.
|
||||
|
||||
## 4. Clasificación técnica
|
||||
|
||||
Los niveles territoriales no usan clasificación técnica:
|
||||
|
||||
- Departamento: sin familia técnica.
|
||||
- Área: sin familia técnica.
|
||||
- Yacimiento: sin familia técnica.
|
||||
|
||||
Los niveles físicos sí la requieren:
|
||||
|
||||
- Instalación: una familia de nivel `INSTALLATION`.
|
||||
- Subinstalación: una familia de nivel `SUBINSTALLATION` compatible con la familia de la Instalación padre.
|
||||
|
||||
La compatibilidad se almacena en `inventory_family_parent_rules` y se valida también en PostgreSQL. Una misma familia de Subinstalación puede ser compatible con varias familias de Instalación.
|
||||
|
||||
Los campos técnicos específicos de cada familia se definen en `inventory_family_attribute_definitions` y sus valores por Inventario en `asset_inventory_attribute_values`.
|
||||
|
||||
## 5. Hallazgos
|
||||
|
||||
Un Hallazgo puede asignarse directamente a:
|
||||
|
||||
- Yacimiento.
|
||||
- Instalación.
|
||||
- Subinstalación.
|
||||
|
||||
No se asignan Hallazgos directos a Departamento ni Área.
|
||||
|
||||
`OTROS / Agregar otro` debe estar siempre disponible. Permite describir un Hallazgo que no exista todavía en el catálogo aplicable y dejarlo registrado para revisión posterior.
|
||||
|
||||
Para Yacimiento, la aplicabilidad del catálogo puede resolverse por tipo de Inventario y por excepciones del objeto concreto. Yacimiento no necesita una familia técnica para recibir Hallazgos.
|
||||
|
||||
Instalación y Subinstalación deben estar clasificadas antes de recibir un Hallazgo.
|
||||
|
||||
## 6. Inventario nacido en campo
|
||||
|
||||
La APK puede incorporar Inventario encontrado durante una Inspección. Un registro creado en campo conserva su trazabilidad de origen y, antes de permitir Hallazgos sobre él, debe tener como mínimo:
|
||||
|
||||
- GPS capturado para la creación.
|
||||
- al menos una fotografía de campo.
|
||||
- clasificación técnica cuando se trate de Instalación o Subinstalación.
|
||||
|
||||
La excepción transitoria que permite crear un registro `FIELD_SURVEY + DRAFT` sin familia existe únicamente para completar la transacción de alta; no habilita Hallazgos sin clasificación.
|
||||
|
||||
## 7. Carga manual recomendada
|
||||
|
||||
El orden seguro para construir una estructura desde cero es:
|
||||
|
||||
1. Crear las Empresas necesarias como maestros independientes.
|
||||
2. Crear Departamento.
|
||||
3. Crear Área dentro del Departamento.
|
||||
4. Registrar la Operadora vigente del Área, si ya se conoce.
|
||||
5. Crear Yacimiento dentro del Área.
|
||||
6. Crear Instalación dentro del Yacimiento y seleccionar su familia técnica.
|
||||
7. Crear Subinstalación dentro de la Instalación y seleccionar una familia compatible.
|
||||
|
||||
La importación de archivos no debe definir la arquitectura del modelo. Los datos externos se adaptan a este contrato; el contrato no se deforma para adaptarse a cada planilla.
|
||||
|
||||
## 8. Historia y consulta temporal
|
||||
|
||||
DH conserva dos conceptos separados:
|
||||
|
||||
- historia física/contextual del Inventario;
|
||||
- historia temporal de la relación Área ⇄ Empresa.
|
||||
|
||||
Los cambios relevantes generan versiones y eventos de auditoría. Las consultas históricas deben utilizar la vigencia correspondiente a la fecha consultada, no sólo la fotografía actual de las columnas de `assets`.
|
||||
|
||||
## 9. Barreras de integridad
|
||||
|
||||
La consistencia no depende sólo de la interfaz. Se protege en varias capas:
|
||||
|
||||
- DTO y servicios de API validan niveles y padres.
|
||||
- PostgreSQL valida la jerarquía canónica.
|
||||
- PostgreSQL valida nivel y compatibilidad de familias técnicas.
|
||||
- la planificación valida una relación Operadora ⇄ Área vigente.
|
||||
- los Hallazgos validan nivel permitido, clasificación técnica y pertenencia a la Inspección/Acta.
|
||||
- CI ejecuta la cadena completa de migraciones sobre PostGIS limpio y los contratos transversales.
|
||||
|
||||
## 10. Corte limpio F5.1
|
||||
|
||||
`F51CleanManualInventory1790087400000` fue un corte intencional de inicio limpio. Eliminó datos operativos/importados de desarrollo y dejó los maestros técnicos necesarios para reconstruir manualmente una estructura confiable.
|
||||
|
||||
Es una migración deliberadamente no reversible mediante `migration:revert`. Su recuperación depende del backup PRE del despliegue. No debe copiarse este patrón para migraciones normales sin una decisión explícita y una estrategia de restauración probada.
|
||||
|
||||
## 11. Regla para cambios futuros
|
||||
|
||||
Cualquier modificación del modelo debe mantener simultáneamente:
|
||||
|
||||
1. la jerarquía física canónica;
|
||||
2. Empresa fuera del árbol;
|
||||
3. relación temporal Área ⇄ Empresa como fuente de verdad operativa;
|
||||
4. compatibilidad técnica de familias;
|
||||
5. trazabilidad histórica;
|
||||
6. protección en base de datos y tests, no sólo en la UI.
|
||||
@@ -0,0 +1,5 @@
|
||||
# F6 · Modelo de Inventarios
|
||||
|
||||
Este archivo se conserva por compatibilidad con referencias históricas.
|
||||
|
||||
La documentación canónica y mantenida está en [`F6_INVENTORY_MODEL.md`](./F6_INVENTORY_MODEL.md).
|
||||
@@ -0,0 +1,5 @@
|
||||
# F6 · Modelo de Inventarios
|
||||
|
||||
Este archivo se conserva por compatibilidad con referencias históricas.
|
||||
|
||||
La documentación canónica y mantenida está en [`F6_INVENTORY_MODEL.md`](./F6_INVENTORY_MODEL.md).
|
||||
@@ -0,0 +1,370 @@
|
||||
# Manual del Programador · DH Inspección V2
|
||||
|
||||
**Baseline documental:** F6.1
|
||||
**API:** 0.29.0-1
|
||||
**WEB:** 0.23.0-1
|
||||
**Android:** 0.15.0
|
||||
|
||||
Este manual describe la arquitectura, las reglas que no deben romperse y el procedimiento seguro para modificar DH Inspección V2. No reemplaza los tests ni las restricciones de base de datos: los tres forman juntos el contrato del sistema.
|
||||
|
||||
---
|
||||
|
||||
## 1. Estructura del repositorio
|
||||
|
||||
- `api-v3/`: API NestJS + TypeORM + PostgreSQL/PostGIS.
|
||||
- `web-v2/`: aplicación WEB React/Vite.
|
||||
- `android-app/`: aplicación Android Kotlin/Compose.
|
||||
- `scripts/`: despliegue, preflight y utilidades operativas.
|
||||
- `docs/`: documentación funcional y técnica viva.
|
||||
- `.github/workflows/`: CI de API/WEB/Docker y build/test de Android.
|
||||
|
||||
El backend expone `/api/v3`. Los cambios de dominio deben resolverse primero en API/base de datos; WEB y Android consumen ese contrato y no deben inventar reglas paralelas.
|
||||
|
||||
---
|
||||
|
||||
## 2. Invariantes de dominio
|
||||
|
||||
### 2.1 Inventario físico
|
||||
|
||||
La jerarquía canónica es:
|
||||
|
||||
**Departamento → Área → Yacimiento → Instalación → Subinstalación**
|
||||
|
||||
`parent_id` expresa exclusivamente esa jerarquía. No agregue Empresa dentro de este árbol.
|
||||
|
||||
### 2.2 Empresa y Operadora
|
||||
|
||||
`Empresa` es un maestro independiente.
|
||||
|
||||
La relación operativa vigente se modela en `area_company_relations`:
|
||||
|
||||
- Área ↔ Empresa.
|
||||
- `relation_role = 'OPERATOR'` para la Operadora.
|
||||
- `valid_from` / `valid_until` para vigencia.
|
||||
|
||||
Esta tabla es la fuente temporal de verdad. `assets.operator_company_id` es una fotografía de compatibilidad; nunca debe usarse como sustituto del historial.
|
||||
|
||||
Cambiar Operadora no mueve Yacimientos, Instalaciones ni Subinstalaciones.
|
||||
|
||||
### 2.3 Clasificación técnica
|
||||
|
||||
- Departamento: sin familia.
|
||||
- Área: sin familia.
|
||||
- Yacimiento: sin familia.
|
||||
- Instalación: requiere familia `INSTALLATION`.
|
||||
- Subinstalación: requiere familia `SUBINSTALLATION` compatible con la Instalación padre.
|
||||
|
||||
La compatibilidad está protegida por PostgreSQL, no sólo por la UI.
|
||||
|
||||
### 2.4 Hallazgos
|
||||
|
||||
Un Hallazgo directo sólo puede asignarse a:
|
||||
|
||||
- Yacimiento.
|
||||
- Instalación.
|
||||
- Subinstalación.
|
||||
|
||||
Departamento y Área no reciben Hallazgos directos.
|
||||
|
||||
`OTROS / Agregar otro` debe permanecer disponible siempre. En Yacimiento no se exige familia técnica. En Instalación/Subinstalación sí.
|
||||
|
||||
Si un elemento fue creado en campo, antes de admitir un Hallazgo debe tener GPS de creación y al menos una fotografía; Instalación/Subinstalación además deben quedar clasificadas.
|
||||
|
||||
### 2.5 Inspecciones
|
||||
|
||||
Lifecycle operativo:
|
||||
|
||||
`DRAFT → PLANNED → IN_PROGRESS → CLOSED`
|
||||
|
||||
La planificación requiere:
|
||||
|
||||
- Área.
|
||||
- Operadora vigente del Área.
|
||||
- Inspector responsable habilitado.
|
||||
- fecha/hora prevista de inicio.
|
||||
- checklist generado.
|
||||
|
||||
La APK puede abrir una Inspección, pero no posee un lifecycle alternativo: el endpoint móvil crea la visita y utiliza los mismos servicios `plan()` y `start()` que el resto del sistema.
|
||||
|
||||
### 2.6 Actas
|
||||
|
||||
Una Inspección puede tener **múltiples Actas**.
|
||||
|
||||
Reglas críticas:
|
||||
|
||||
- sólo puede existir un `DRAFT` simultáneo por Inspección;
|
||||
- un Acta en `DRAFT` es editable;
|
||||
- al finalizar se bloquea y queda inmutable;
|
||||
- el sellado conserva integridad y firmas;
|
||||
- no existe reapertura normal de un Acta bloqueada a borrador;
|
||||
- para cerrar la Inspección todas las Actas no canceladas deben estar `SEALED`;
|
||||
- el Informe pertenece al Acta, no a la Inspección completa.
|
||||
|
||||
La unicidad del borrador está protegida por el índice parcial `uq_inspection_acts_one_draft_per_visit`.
|
||||
|
||||
### 2.7 Informe, GEDO y vencimientos
|
||||
|
||||
Flujo conceptual:
|
||||
|
||||
**Acta → INF → oficialización GEDO/IF**
|
||||
|
||||
GEDO oficializa el Informe, pero **no equivale por sí solo a la notificación administrativa que inicia el plazo No urgente**.
|
||||
|
||||
Existe una compatibilidad histórica importante: el valor físico `GEDO_DATE` se conserva en el enum/base, mientras el significado funcional vigente es `NOTIFICATION_DATE`. No cambie el valor persistido en una refactorización sin una migración explícita y pruebas de compatibilidad.
|
||||
|
||||
---
|
||||
|
||||
## 3. Modelo de Inventarios
|
||||
|
||||
La referencia completa es [`F6_INVENTORY_MODEL.md`](./F6_INVENTORY_MODEL.md).
|
||||
|
||||
Puntos técnicos centrales:
|
||||
|
||||
- `assets.parent_id`: pertenencia física.
|
||||
- `assets.operational_area_id`: contexto transversal de Área.
|
||||
- `area_company_relations`: historia de Operadora.
|
||||
- `inventory_families`: clasificaciones técnicas.
|
||||
- `inventory_family_parent_rules`: compatibilidad Subinstalación ↔ Instalación.
|
||||
- `inventory_family_attribute_definitions`: campos técnicos por familia.
|
||||
- `asset_inventory_attribute_values`: valores técnicos por objeto.
|
||||
- `asset_context_history` y versiones: trazabilidad contextual/histórica.
|
||||
|
||||
No se debe rediseñar el esquema para coincidir con una planilla importada. Los archivos externos se normalizan al modelo canónico.
|
||||
|
||||
---
|
||||
|
||||
## 4. Migraciones
|
||||
|
||||
### Regla principal
|
||||
|
||||
**Nunca modifique una migración ya aplicada.**
|
||||
|
||||
Si cambia esquema, constraints, seeds o reglas persistentes:
|
||||
|
||||
1. cree una migración nueva;
|
||||
2. haga `up()` determinista;
|
||||
3. agregue verificación al final cuando la regla sea crítica;
|
||||
4. implemente `down()` cuando la operación sea razonablemente reversible;
|
||||
5. agregue/actualice tests de contrato;
|
||||
6. pruebe la cadena completa sobre PostGIS limpio.
|
||||
|
||||
### F5.1 es una excepción histórica
|
||||
|
||||
`F51CleanManualInventory1790087400000` fue un corte destructivo intencional para reiniciar los datos de dominio. Su `down()` no intenta reconstruir información eliminada: el rollback depende del backup PRE.
|
||||
|
||||
No repita este patrón para una migración normal sin una decisión explícita, backup validado y procedimiento de restauración.
|
||||
|
||||
### Guards importantes de base de datos
|
||||
|
||||
PostgreSQL protege, entre otras cosas:
|
||||
|
||||
- jerarquía Departamento → Área → Yacimiento → Instalación → Subinstalación;
|
||||
- familia correcta según el nivel físico;
|
||||
- compatibilidad de familia de Subinstalación con su Instalación padre;
|
||||
- un solo Acta `DRAFT` por Inspección.
|
||||
|
||||
Las validaciones de API mejoran la experiencia, pero no reemplazan estos guards.
|
||||
|
||||
---
|
||||
|
||||
## 5. API
|
||||
|
||||
### Principios
|
||||
|
||||
- Reutilice servicios canónicos; no duplique lifecycle en controllers móviles.
|
||||
- Las mutaciones relevantes deben producir auditoría.
|
||||
- Los errores de dominio deben tener `code` estable y mensaje legible.
|
||||
- Los estados históricos/inmutables no deben reabrirse por conveniencia de UI.
|
||||
- Para relaciones temporales, valide la vigencia en la fecha correspondiente, no sólo `valid_until IS NULL` cuando el caso exige reconstrucción histórica.
|
||||
|
||||
### Áreas a revisar antes de tocar un flujo
|
||||
|
||||
- Inventario: `src/asset-master/`.
|
||||
- Inspecciones: `src/inspection-visits/`.
|
||||
- Actas: `src/inspection-acts/`, `src/inspection-closing/`, `src/act-administration/`.
|
||||
- Hallazgos: `src/inspection-findings/` y gate móvil en `src/inspection-visits/field-findings.service.ts`.
|
||||
- Informes/GEDO: `src/inspection-reports/`.
|
||||
- Verificaciones: `src/inspection-verifications/`.
|
||||
- Entidades/migraciones: `src/database/`.
|
||||
|
||||
---
|
||||
|
||||
## 6. WEB
|
||||
|
||||
La WEB es el frente principal de administración, planificación y seguimiento. Las rutas protegidas se declaran en `web-v2/src/app/App.tsx` y usan permisos para controlar acceso.
|
||||
|
||||
Módulos principales:
|
||||
|
||||
- Dashboard.
|
||||
- Mapa.
|
||||
- Inventarios y dossier.
|
||||
- Revisión de Inventario nacido en campo.
|
||||
- Inspecciones y preparación de campo.
|
||||
- Actas.
|
||||
- Hallazgos y planificación de verificaciones.
|
||||
- Seguimiento de Actas.
|
||||
- Informes.
|
||||
- Historial y consulta temporal.
|
||||
- Administración de usuarios, roles, auditoría, tipos y catálogo.
|
||||
|
||||
No esconda una regla sólo en la WEB: toda restricción de integridad debe existir también en API/DB.
|
||||
|
||||
---
|
||||
|
||||
## 7. Android
|
||||
|
||||
La APK está orientada a ejecución en campo.
|
||||
|
||||
Reglas que deben mantenerse:
|
||||
|
||||
- sólo un usuario con capacidad de ejecución puede iniciar/operar una Inspección;
|
||||
- `Abrir inspección` selecciona Área y Operadora vigente;
|
||||
- la nueva visita queda autoasignada al inspector actual;
|
||||
- se genera checklist antes de iniciar;
|
||||
- el backend recorre DRAFT → PLANNED → IN_PROGRESS con el lifecycle canónico;
|
||||
- Inventario nuevo en campo captura evidencia mínima antes de Hallazgos;
|
||||
- Hallazgos respetan los mismos niveles y catálogo que la API.
|
||||
|
||||
Al cambiar contratos móviles, actualice tanto Kotlin como los tests de contrato montados por el preflight de API.
|
||||
|
||||
---
|
||||
|
||||
## 8. Permisos y seguridad
|
||||
|
||||
El sistema utiliza permisos finos; las pantallas no deben asumir permisos por nombre visual del rol. La fuente efectiva es la matriz rol-permiso de base de datos.
|
||||
|
||||
Buenas prácticas:
|
||||
|
||||
- proteja controllers con permisos explícitos;
|
||||
- no confíe en que una ruta esté oculta en la WEB;
|
||||
- preserve la política especial del inspector móvil;
|
||||
- mantenga auditadas las mutaciones administrativas;
|
||||
- no exponga secretos ni credenciales en repositorio, logs o documentación.
|
||||
|
||||
---
|
||||
|
||||
## 9. Tests y CI
|
||||
|
||||
La barrera principal está en `.github/workflows/ci.yml`.
|
||||
|
||||
### API
|
||||
|
||||
Ejecuta:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
### WEB
|
||||
|
||||
Ejecuta:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run typecheck
|
||||
bash ../scripts/check-f3-1-web-contract.sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Docker / contratos
|
||||
|
||||
Valida:
|
||||
|
||||
- sintaxis de scripts shell;
|
||||
- paridad del preflight de deploy;
|
||||
- `docker compose config`;
|
||||
- cadena de migraciones sobre PostGIS limpio;
|
||||
- estado limpio F5.1 y preservación de maestros técnicos;
|
||||
- segunda ejecución sin migraciones pendientes;
|
||||
- preflight API aislado equivalente al VPS;
|
||||
- build de imágenes productivas.
|
||||
|
||||
### Android
|
||||
|
||||
El workflow específico de Android debe completar:
|
||||
|
||||
- `assembleDebug`;
|
||||
- unit tests;
|
||||
- publicación del artefacto APK.
|
||||
|
||||
### Contrato transversal F6.1
|
||||
|
||||
`api-v3/test/unit/f6-1-project-invariants.test.ts` fija las reglas que más fácilmente podrían degradarse por refactors: jerarquía, contexto Área+Operadora, multi-Acta, Hallazgos, apertura móvil y separación GEDO/vencimiento.
|
||||
|
||||
---
|
||||
|
||||
## 10. Versionado
|
||||
|
||||
Cuando una entrega cambia versión, revise como mínimo:
|
||||
|
||||
- `api-v3/package.json`;
|
||||
- `api-v3/src/version.ts`;
|
||||
- `web-v2/package.json`;
|
||||
- `android-app/app/build.gradle.kts` si cambia APK;
|
||||
- tests que fijen metadata de versión;
|
||||
- metadata raíz de `package-lock.json` regenerándola con npm cuando corresponda.
|
||||
|
||||
Los lockfiles son archivos generados: no los edite manualmente en forma parcial. Use npm para reconciliarlos.
|
||||
|
||||
---
|
||||
|
||||
## 11. Flujo Git y despliegue
|
||||
|
||||
Secuencia recomendada:
|
||||
|
||||
1. crear rama desde `main` estable;
|
||||
2. implementar código + migración + tests + documentación;
|
||||
3. abrir PR;
|
||||
4. exigir API + WEB + Docker/migraciones + Android verdes cuando aplique;
|
||||
5. mergear a `main`;
|
||||
6. exigir nuevamente CI verde sobre el SHA de `main`;
|
||||
7. recién entonces promover el SHA exacto a `deploy`;
|
||||
8. esperar el watcher/deploy;
|
||||
9. comprobar SHA objetivo, health, versiones y backup generado.
|
||||
|
||||
Nunca despliegue un SHA distinto al que pasó la barrera. Nunca use `deploy` para “probar” una rama incompleta.
|
||||
|
||||
Si una migración es de una sola vía, el rollback operativo es el backup PRE validado, no un `migration:revert` improvisado.
|
||||
|
||||
---
|
||||
|
||||
## 12. Checklist antes de aprobar un cambio
|
||||
|
||||
- ¿Respeta la jerarquía canónica?
|
||||
- ¿Empresa sigue fuera del árbol físico?
|
||||
- ¿Una modificación de Operadora conserva historia temporal?
|
||||
- ¿La regla importante está protegida también en API/DB?
|
||||
- ¿Hallazgos siguen limitados a Yacimiento/Instalación/Subinstalación?
|
||||
- ¿OTROS sigue disponible?
|
||||
- ¿Una Inspección puede tener varias Actas y sólo un DRAFT simultáneo?
|
||||
- ¿Actas finalizadas siguen inmutables?
|
||||
- ¿Informe sigue perteneciendo al Acta?
|
||||
- ¿GEDO sigue separado del inicio automático del plazo No urgente?
|
||||
- ¿WEB y Android consumen el mismo contrato?
|
||||
- ¿Se agregó un test para la nueva regla o regresión?
|
||||
- ¿CI completa está verde?
|
||||
- ¿Documentación viva quedó actualizada?
|
||||
|
||||
---
|
||||
|
||||
## 13. Terminología canónica
|
||||
|
||||
Use consistentemente:
|
||||
|
||||
- **Inventario**, no “Activo” en la interfaz de usuario cuando se refiere al módulo funcional.
|
||||
- **Departamento**.
|
||||
- **Área**.
|
||||
- **Yacimiento**.
|
||||
- **Instalación**.
|
||||
- **Subinstalación**.
|
||||
- **Empresa / Operadora** como maestro/rol operativo, no como padre físico.
|
||||
- **Inspección** para la visita operativa.
|
||||
- **Acta** para el documento de campo; una Inspección puede contener varias.
|
||||
- **Hallazgo** para la observación fiscalizable asociada a Inventario.
|
||||
- **Informe / INF** por Acta.
|
||||
- **GEDO / IF** para la oficialización documental, separada de la notificación administrativa cuando corresponda.
|
||||
|
||||
Si una etiqueta histórica contradice estos términos, corrija la etiqueta; no cambie el modelo para conservar una denominación obsoleta.
|
||||
@@ -0,0 +1,353 @@
|
||||
# Manual de Usuario · DH Inspección V2
|
||||
|
||||
**Versión funcional de referencia:** F6.1
|
||||
|
||||
Este manual explica el uso cotidiano del sistema. Las opciones visibles dependen de los permisos asignados a cada usuario, por lo que un Inspector, Supervisor, Director o Administrador puede ver acciones distintas.
|
||||
|
||||
---
|
||||
|
||||
## 1. Conceptos principales
|
||||
|
||||
DH Inspección V2 organiza el trabajo alrededor de cuatro elementos:
|
||||
|
||||
1. **Inventario:** dónde se inspecciona.
|
||||
2. **Inspección:** la visita de trabajo.
|
||||
3. **Acta:** documento generado durante la Inspección; una Inspección puede tener varias Actas.
|
||||
4. **Hallazgo:** observación asociada a un elemento concreto del Inventario.
|
||||
|
||||
Además, cada Acta puede continuar con su **Informe (INF)**, oficialización documental y seguimiento posterior.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ingreso al sistema
|
||||
|
||||
1. Abra la WEB de DH Inspección V2.
|
||||
2. Ingrese usuario y contraseña.
|
||||
3. El menú mostrará solamente los módulos habilitados por sus permisos.
|
||||
|
||||
Si una opción no aparece o el sistema informa que no tiene autorización, consulte al Administrador antes de intentar resolverlo creando datos alternativos.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cómo se organiza el Inventario
|
||||
|
||||
La estructura física correcta es siempre:
|
||||
|
||||
**Departamento → Área → Yacimiento → Instalación → Subinstalación**
|
||||
|
||||
La **Empresa** no forma parte de esa cadena. Se administra por separado y se vincula al Área cuando actúa como Operadora.
|
||||
|
||||
Ejemplo:
|
||||
|
||||
- Departamento: Malargüe
|
||||
- Área: Área A
|
||||
- Yacimiento: Yacimiento Norte
|
||||
- Instalación: Planta de Tratamiento
|
||||
- Subinstalación: Separador 01
|
||||
|
||||
Una Empresa puede cambiar con el tiempo sin modificar esta estructura física.
|
||||
|
||||
---
|
||||
|
||||
## 4. Carga manual de Inventario
|
||||
|
||||
Para construir una estructura confiable desde cero, use este orden:
|
||||
|
||||
1. Cree las **Empresas** necesarias.
|
||||
2. Cree el **Departamento**.
|
||||
3. Cree el **Área** dentro del Departamento.
|
||||
4. Registre la **Operadora vigente** del Área si corresponde.
|
||||
5. Cree el **Yacimiento** dentro del Área.
|
||||
6. Cree la **Instalación** dentro del Yacimiento.
|
||||
7. Seleccione la clasificación técnica de la Instalación.
|
||||
8. Cree la **Subinstalación** dentro de la Instalación.
|
||||
9. Seleccione una clasificación técnica compatible para la Subinstalación.
|
||||
|
||||
El sistema no permite saltear niveles. Por ejemplo, una Instalación no puede depender directamente de un Área.
|
||||
|
||||
### Empresa y Operadora
|
||||
|
||||
Cuando una Empresa opera un Área, registre la relación operativa correspondiente. Si cambia la Operadora:
|
||||
|
||||
1. finalice la relación vigente anterior;
|
||||
2. registre la nueva Operadora;
|
||||
3. no mueva Yacimientos, Instalaciones ni Subinstalaciones.
|
||||
|
||||
El historial queda conservado para consultas temporales.
|
||||
|
||||
---
|
||||
|
||||
## 5. Consultar Inventario
|
||||
|
||||
Desde **Inventarios** puede navegar la estructura territorial y abrir el dossier de cada registro.
|
||||
|
||||
Utilice el breadcrumb para volver hacia niveles superiores. El dossier concentra, según la información disponible:
|
||||
|
||||
- datos actuales;
|
||||
- ubicación y clasificación;
|
||||
- fotografías;
|
||||
- historial de cambios;
|
||||
- Hallazgos;
|
||||
- verificaciones;
|
||||
- Actas e Informes relacionados.
|
||||
|
||||
Cuando necesite saber cómo estaba un elemento en una fecha anterior, use la **Consulta temporal** en lugar de interpretar sólo sus datos actuales.
|
||||
|
||||
---
|
||||
|
||||
## 6. Crear una Inspección desde la WEB
|
||||
|
||||
En **Inspecciones → Nueva Inspección**:
|
||||
|
||||
1. seleccione el **Área**;
|
||||
2. seleccione una **Operadora vigente** para esa Área;
|
||||
3. seleccione el **Inspector responsable**;
|
||||
4. indique la fecha y hora prevista de inicio;
|
||||
5. confirme la creación.
|
||||
|
||||
Al crearse la Inspección, el sistema genera su checklist de antecedentes. Antes de planificar, la información obligatoria debe estar completa.
|
||||
|
||||
Una Inspección planificada se inicia operativamente desde la APK por un Inspector habilitado y asignado.
|
||||
|
||||
---
|
||||
|
||||
## 7. Abrir una Inspección directamente desde la APK
|
||||
|
||||
La APK también permite comenzar una Inspección que no haya sido preparada previamente en la WEB.
|
||||
|
||||
Desde la pantalla principal:
|
||||
|
||||
1. pulse **Abrir inspección**;
|
||||
2. elija el **Área**;
|
||||
3. elija la **Operadora vigente**;
|
||||
4. pulse **Abrir inspección ahora**.
|
||||
|
||||
El sistema:
|
||||
|
||||
- crea la Inspección;
|
||||
- la asigna al Inspector que inició sesión;
|
||||
- genera su checklist;
|
||||
- la planifica;
|
||||
- la deja **En curso**.
|
||||
|
||||
No elija una Empresa por aproximación: la APK muestra la relación operativa válida del Área.
|
||||
|
||||
---
|
||||
|
||||
## 8. Trabajar con Inventario durante una Inspección
|
||||
|
||||
Dentro de una Inspección en curso puede seleccionar Inventario existente del Área y agregarlo al trabajo de campo/Acta correspondiente.
|
||||
|
||||
Los niveles relevantes para Hallazgos son:
|
||||
|
||||
- Yacimiento;
|
||||
- Instalación;
|
||||
- Subinstalación.
|
||||
|
||||
### Inventario encontrado en campo
|
||||
|
||||
Si encuentra una Instalación o Subinstalación que todavía no existe, la APK puede darla de alta dentro de la jerarquía correspondiente.
|
||||
|
||||
Antes de poder registrar Hallazgos sobre un elemento nuevo de campo, debe quedar como mínimo con:
|
||||
|
||||
- GPS de creación;
|
||||
- al menos una fotografía;
|
||||
- clasificación técnica si es Instalación o Subinstalación.
|
||||
|
||||
Esto evita que un Hallazgo quede asociado a un objeto sin identificación suficiente.
|
||||
|
||||
---
|
||||
|
||||
## 9. Actas: una Inspección puede tener varias
|
||||
|
||||
Una misma Inspección puede generar **varias Actas**.
|
||||
|
||||
La regla práctica es:
|
||||
|
||||
- puede haber varias Actas históricas dentro de la Inspección;
|
||||
- sólo puede existir **un Acta en borrador al mismo tiempo**.
|
||||
|
||||
Mientras un Acta está en borrador puede completar su contenido y agregar Hallazgos. Una vez finalizada/bloqueada deja de ser editable como un borrador normal.
|
||||
|
||||
Para continuar con una nueva Acta dentro de la misma Inspección, finalice correctamente la actual antes de abrir la siguiente.
|
||||
|
||||
---
|
||||
|
||||
## 10. Registrar Hallazgos
|
||||
|
||||
Los Hallazgos se asignan directamente a:
|
||||
|
||||
- **Yacimiento**;
|
||||
- **Instalación**;
|
||||
- **Subinstalación**.
|
||||
|
||||
No se crean Hallazgos directos sobre Departamento ni Área.
|
||||
|
||||
### Hallazgo de catálogo
|
||||
|
||||
Si el sistema muestra un Hallazgo aplicable al elemento seleccionado:
|
||||
|
||||
1. elija el Hallazgo del catálogo;
|
||||
2. complete la descripción y datos requeridos;
|
||||
3. guarde.
|
||||
|
||||
### OTROS / Agregar otro
|
||||
|
||||
La opción **OTROS** debe estar siempre disponible.
|
||||
|
||||
Úsela cuando el Hallazgo observado no exista en la lista disponible. Describa claramente qué se encontró para que luego pueda revisarse y normalizarse en oficina.
|
||||
|
||||
Después de guardar un Hallazgo puede utilizar **Agregar otro** para continuar cargando Hallazgos sobre el mismo Inventario.
|
||||
|
||||
### En Yacimiento
|
||||
|
||||
Un Yacimiento puede recibir Hallazgos aunque no tenga clasificación técnica. Cuando exista catálogo configurado para ese tipo, el sistema puede ofrecerlo; en todos los casos conserva la opción OTROS.
|
||||
|
||||
---
|
||||
|
||||
## 11. Finalizar y sellar un Acta
|
||||
|
||||
Antes de finalizar un Acta revise especialmente:
|
||||
|
||||
- Inventario incluido;
|
||||
- Hallazgos;
|
||||
- observaciones;
|
||||
- firmas y datos requeridos por el flujo.
|
||||
|
||||
Al finalizar, el sistema bloquea el contenido y genera controles de integridad. Esto significa que **el Acta deja de ser editable como borrador**.
|
||||
|
||||
No finalice un Acta para “probar” cómo queda. Primero revise todo su contenido.
|
||||
|
||||
Cuando el flujo de firmas y sellado termina, el Acta queda `SELLADA` y preparada para continuar con su documentación posterior.
|
||||
|
||||
---
|
||||
|
||||
## 12. Cerrar una Inspección
|
||||
|
||||
Para cerrar una Inspección en curso deben cumplirse las condiciones operativas, entre ellas:
|
||||
|
||||
- existir al menos un Acta;
|
||||
- todas las Actas no canceladas deben estar selladas;
|
||||
- si la Inspección contiene verificaciones, sus resultados deben estar registrados.
|
||||
|
||||
Si el sistema impide el cierre, revise el mensaje mostrado: normalmente identifica el Acta o la verificación pendiente.
|
||||
|
||||
---
|
||||
|
||||
## 13. Informes por Acta
|
||||
|
||||
El Informe se genera y administra **por Acta**.
|
||||
|
||||
Por eso, si una Inspección tuvo tres Actas, el seguimiento documental no debe tratarla como si existiera un único Informe global.
|
||||
|
||||
Desde **Informes** puede consultar el estado del INF y continuar el flujo habilitado para su rol.
|
||||
|
||||
---
|
||||
|
||||
## 14. GEDO / IF y plazo No urgente
|
||||
|
||||
Registrar el identificador y PDF oficial de GEDO/IF **oficializa el Informe**, pero por sí solo no significa que haya comenzado automáticamente el plazo administrativo de una Acta No urgente.
|
||||
|
||||
El vencimiento debe activarse a partir del evento administrativo de notificación que corresponda al procedimiento vigente.
|
||||
|
||||
Si ve un Informe oficializado sin fecha de vencimiento activa, no lo corrija cargando una fecha ficticia de GEDO como notificación.
|
||||
|
||||
---
|
||||
|
||||
## 15. Seguimiento de Hallazgos
|
||||
|
||||
Después del trabajo de campo, un Hallazgo mantiene su historia. El seguimiento puede incluir:
|
||||
|
||||
- respuesta de la Empresa;
|
||||
- documentos y evidencias;
|
||||
- fecha de control;
|
||||
- planificación de una verificación;
|
||||
- resultado de verificación;
|
||||
- nueva fecha si continúa pendiente;
|
||||
- cierre administrativo.
|
||||
|
||||
No reescriba el Hallazgo original para reflejar novedades posteriores. Use los eventos de seguimiento para conservar la trazabilidad.
|
||||
|
||||
---
|
||||
|
||||
## 16. Verificaciones
|
||||
|
||||
Cuando un Hallazgo requiere control posterior:
|
||||
|
||||
1. defina la fecha de verificación cuando corresponda;
|
||||
2. planifique la visita de verificación;
|
||||
3. ejecute la verificación;
|
||||
4. registre el resultado y evidencia;
|
||||
5. si no está resuelto, reprograme según el flujo;
|
||||
6. si está resuelto y la visita quedó cerrada, continúe con el cierre administrativo del Hallazgo.
|
||||
|
||||
El historial de verificaciones no debe borrarse para reemplazarlo por el último resultado.
|
||||
|
||||
---
|
||||
|
||||
## 17. Historial y Consulta temporal
|
||||
|
||||
Use **Historial** para revisar cambios y eventos registrados.
|
||||
|
||||
Use **Consulta temporal** cuando la pregunta sea, por ejemplo:
|
||||
|
||||
- “¿qué Empresa operaba esta Área en esa fecha?”;
|
||||
- “¿dónde estaba ubicado este elemento?”;
|
||||
- “¿qué estado tenía el Inventario en ese momento?”.
|
||||
|
||||
La vista actual y la vista histórica cumplen funciones distintas.
|
||||
|
||||
---
|
||||
|
||||
## 18. Revisión de Inventario nacido en campo
|
||||
|
||||
Los registros creados por Inspectores en la APK pueden requerir revisión posterior en oficina.
|
||||
|
||||
Al revisar:
|
||||
|
||||
- confirme nombre/código;
|
||||
- confirme la ubicación jerárquica;
|
||||
- verifique clasificación técnica;
|
||||
- revise GPS/fotografías;
|
||||
- normalice información pendiente sin perder el origen de campo.
|
||||
|
||||
No cree un duplicado sólo porque el nombre no coincide exactamente; primero confirme si se trata del mismo objeto.
|
||||
|
||||
---
|
||||
|
||||
## 19. Mensajes frecuentes
|
||||
|
||||
### “La Operadora seleccionada no tiene una relación operativa vigente con el Área”
|
||||
|
||||
Revise la relación Empresa ⇄ Área y su vigencia. No cambie la jerarquía física para resolverlo.
|
||||
|
||||
### “La Instalación/Subinstalación debe tener una clasificación técnica”
|
||||
|
||||
Complete la familia técnica del elemento antes de registrar el Hallazgo.
|
||||
|
||||
### “El Inventario creado en campo debe tener GPS y al menos una foto”
|
||||
|
||||
Complete la captura de campo requerida y vuelva a intentar.
|
||||
|
||||
### “Todas las Actas deben estar SELLADAS antes de cerrar la Inspección”
|
||||
|
||||
Abra la Inspección y revise cuáles Actas todavía están en borrador, bloqueadas o pendientes de sellado.
|
||||
|
||||
### “La inspección no tiene un Acta borrador abierta”
|
||||
|
||||
Cree/abra un Acta borrador para esa Inspección antes de cargar nuevos Hallazgos.
|
||||
|
||||
---
|
||||
|
||||
## 20. Buenas prácticas
|
||||
|
||||
- Cargue la estructura de Inventario en orden jerárquico.
|
||||
- No use Empresa como padre físico.
|
||||
- No duplique Inventario ante la primera diferencia de nombre.
|
||||
- Use OTROS cuando el Hallazgo no exista en catálogo.
|
||||
- Revise un Acta antes de finalizarla: luego queda inmutable.
|
||||
- Use seguimiento y verificaciones para agregar historia; no reescriba hechos anteriores.
|
||||
- Use Consulta temporal para preguntas históricas.
|
||||
- Respete los mensajes de integridad del sistema en lugar de forzar datos ficticios.
|
||||
|
||||
Ante una duda funcional que pueda modificar jerarquía, Actas, Hallazgos o vencimientos, consulte antes de cargar datos masivamente.
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -1,163 +1,56 @@
|
||||
import { apiRequest } from './api';
|
||||
|
||||
export type InventoryStructureKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
|
||||
export type InventoryFamilyAttributeDataType = 'TEXT' | 'NUMBER' | 'BOOLEAN' | 'DATE' | 'DATETIME' | 'SELECT';
|
||||
|
||||
export interface InventoryFamilyParent { id:string; code:string; name:string; }
|
||||
export interface InventoryFamily {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
level: 'INSTALLATION' | 'SUBINSTALLATION';
|
||||
legacyTypeCode?: string | null;
|
||||
informationLabels: string[];
|
||||
sourceReference?: string | null;
|
||||
isActive?: boolean;
|
||||
parentFamilyId: string | null;
|
||||
parentFamilyCode: string | null;
|
||||
parentFamilyName: string | null;
|
||||
assetCount?: number;
|
||||
findingCount?: number;
|
||||
findingItemIds?: string[];
|
||||
id:string; code:string; name:string; level:'INSTALLATION'|'SUBINSTALLATION'; legacyTypeCode?:string|null;
|
||||
informationLabels:string[]; sourceReference?:string|null; isActive?:boolean; parentFamilyIds:string[];
|
||||
parentFamilies:InventoryFamilyParent[]; assetCount?:number; findingCount?:number; findingItemIds?:string[]; technicalAttributeCount?:number;
|
||||
}
|
||||
|
||||
export interface InventoryStructureLevel {
|
||||
kind: InventoryStructureKind;
|
||||
label: string;
|
||||
type: { id: string; code: string; name: string };
|
||||
parentKind: InventoryStructureKind | null;
|
||||
requiresFamily: boolean;
|
||||
export interface InventoryFamilyAttribute {
|
||||
id:string; inventoryFamilyId:string; code:string; name:string; dataType:InventoryFamilyAttributeDataType;
|
||||
isRequired:boolean; isActive:boolean; unit:string|null; options:string[]|null; sortOrder:number;
|
||||
}
|
||||
|
||||
export interface InventoryStructureOptions {
|
||||
independentMasters: InventoryStructureLevel[];
|
||||
levels: InventoryStructureLevel[];
|
||||
installationFamilies: InventoryFamily[];
|
||||
subinstallationFamilies: InventoryFamily[];
|
||||
export interface InventoryFamilyAttributes { family:InventoryFamily; items:InventoryFamilyAttribute[]; count:number; }
|
||||
export interface InventoryTechnicalValues {
|
||||
assetId:string;
|
||||
family:{id:string;code:string;name:string;level:string};
|
||||
definitions:InventoryFamilyAttribute[];
|
||||
values:Record<string,unknown>;
|
||||
}
|
||||
|
||||
export interface InventoryStructureLevel { kind:InventoryStructureKind; label:string; type:{id:string;code:string;name:string}; parentKind:InventoryStructureKind|null; requiresFamily:boolean; }
|
||||
export interface InventoryStructureOptions { independentMasters:InventoryStructureLevel[]; levels:InventoryStructureLevel[]; installationFamilies:InventoryFamily[]; subinstallationFamilies:InventoryFamily[]; }
|
||||
export interface InventoryStructureParent {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
commonName: string | null;
|
||||
type: { id: string; code: string; name: string };
|
||||
inventoryFamily: null | {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
level: 'INSTALLATION' | 'SUBINSTALLATION';
|
||||
informationLabels: string[];
|
||||
};
|
||||
parent: null | { id: string; code: string; name: string };
|
||||
id:string; code:string; name:string; commonName:string|null; type:{id:string;code:string;name:string};
|
||||
inventoryFamily:null|{id:string;code:string;name:string;level:'INSTALLATION'|'SUBINSTALLATION';informationLabels:string[]};
|
||||
parent:null|{id:string;code:string;name:string};
|
||||
}
|
||||
|
||||
export interface InventoryFamilyFinding {
|
||||
id: string;
|
||||
code: string;
|
||||
sourceNumber: number;
|
||||
title: string;
|
||||
legalBasis: string | null;
|
||||
glossary: string | null;
|
||||
suggestedSeverity: number | null;
|
||||
categoryId: string;
|
||||
categoryCode: string;
|
||||
categoryName: string;
|
||||
id:string; code:string; sourceNumber:number; title:string; legalBasis:string|null; glossary:string|null;
|
||||
suggestedSeverity:number|null; categoryId:string; categoryCode:string; categoryName:string;
|
||||
}
|
||||
|
||||
export interface InventoryFamilyFindings {
|
||||
family: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
level: string;
|
||||
informationLabels: string[];
|
||||
};
|
||||
items: InventoryFamilyFinding[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface InventoryFamilyFindings { family:InventoryFamily; items:InventoryFamilyFinding[]; count:number; }
|
||||
export interface CreatedInventoryStructure {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
commonName: string | null;
|
||||
description: string | null;
|
||||
informationStatus: string;
|
||||
operationalStatus: string;
|
||||
type: { id: string; code: string; name: string };
|
||||
parent: null | { id: string; code: string; name: string };
|
||||
operationalArea?: null | { id: string; code: string; name: string };
|
||||
inventoryFamily: null | {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
level: string;
|
||||
informationLabels: string[];
|
||||
};
|
||||
id:string; code:string; name:string; commonName:string|null; description:string|null; informationStatus:string; operationalStatus:string;
|
||||
type:{id:string;code:string;name:string}; parent:null|{id:string;code:string;name:string}; operationalArea?:null|{id:string;code:string;name:string};
|
||||
inventoryFamily:null|{id:string;code:string;name:string;level:string;informationLabels:string[]};
|
||||
}
|
||||
|
||||
export function getInventoryStructureOptions() {
|
||||
return apiRequest<InventoryStructureOptions>('/inventory-structure');
|
||||
}
|
||||
|
||||
export async function listInventoryStructureParents(kind: InventoryStructureKind, search = '') {
|
||||
const query = new URLSearchParams();
|
||||
if (search.trim()) query.set('search', search.trim());
|
||||
const suffix = query.size ? `?${query}` : '';
|
||||
return (await apiRequest<{ data: InventoryStructureParent[] }>(
|
||||
`/inventory-structure/parents/${kind}${suffix}`,
|
||||
)).data;
|
||||
}
|
||||
|
||||
export function getInventoryFamilyFindings(familyId: string) {
|
||||
return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`);
|
||||
}
|
||||
|
||||
export async function listInventoryFamiliesAdmin() {
|
||||
return (await apiRequest<{ data: InventoryFamily[] }>('/inventory-families/admin')).data;
|
||||
}
|
||||
|
||||
export function createInventoryFamily(input: {
|
||||
level: 'INSTALLATION' | 'SUBINSTALLATION';
|
||||
name: string;
|
||||
parentFamilyId?: string | null;
|
||||
informationLabels?: string[];
|
||||
}) {
|
||||
return apiRequest<InventoryFamily>('/inventory-families', {
|
||||
method: 'POST', body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateInventoryFamily(familyId: string, input: {
|
||||
name?: string;
|
||||
parentFamilyId?: string | null;
|
||||
informationLabels?: string[];
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
return apiRequest<InventoryFamily>(`/inventory-families/${familyId}`, {
|
||||
method: 'PATCH', body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function replaceInventoryFamilyFindings(familyId: string, input: {
|
||||
itemIds: string[];
|
||||
reason: string;
|
||||
}) {
|
||||
return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`, {
|
||||
method: 'PUT', body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function createInventoryStructure(input: {
|
||||
kind: InventoryStructureKind;
|
||||
code?: string | null;
|
||||
name: string;
|
||||
commonName?: string | null;
|
||||
parentId?: string | null;
|
||||
familyId?: string | null;
|
||||
description?: string | null;
|
||||
}) {
|
||||
return apiRequest<CreatedInventoryStructure>('/inventory-structure', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
export function getInventoryStructureOptions(){return apiRequest<InventoryStructureOptions>('/inventory-structure');}
|
||||
export async function listInventoryStructureParents(kind:InventoryStructureKind,search=''){
|
||||
const query=new URLSearchParams(); if(search.trim()) query.set('search',search.trim()); const suffix=query.size?`?${query}`:'';
|
||||
return (await apiRequest<{data:InventoryStructureParent[]}>(`/inventory-structure/parents/${kind}${suffix}`)).data;
|
||||
}
|
||||
export function getInventoryFamilyFindings(familyId:string){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`);}
|
||||
export function getInventoryFamilyAttributes(familyId:string){return apiRequest<InventoryFamilyAttributes>(`/inventory-families/${familyId}/attributes`);}
|
||||
export function getInventoryTechnicalValues(assetId:string){return apiRequest<InventoryTechnicalValues>(`/assets/${assetId}/technical-values`);}
|
||||
export function replaceInventoryTechnicalValues(assetId:string,values:Record<string,unknown>){return apiRequest<InventoryTechnicalValues>(`/assets/${assetId}/technical-values`,{method:'PUT',body:JSON.stringify({values})});}
|
||||
export async function listInventoryFamiliesAdmin(){return (await apiRequest<{data:InventoryFamily[]}>('/inventory-families/admin')).data;}
|
||||
export function createInventoryFamily(input:{level:'INSTALLATION'|'SUBINSTALLATION';name:string;parentFamilyIds?:string[];informationLabels?:string[]}){return apiRequest<InventoryFamily>('/inventory-families',{method:'POST',body:JSON.stringify(input)});}
|
||||
export function updateInventoryFamily(familyId:string,input:{name?:string;parentFamilyIds?:string[];informationLabels?:string[];isActive?:boolean}){return apiRequest<InventoryFamily>(`/inventory-families/${familyId}`,{method:'PATCH',body:JSON.stringify(input)});}
|
||||
export function createInventoryFamilyAttribute(familyId:string,input:{code:string;name:string;dataType:InventoryFamilyAttributeDataType;isRequired?:boolean;unit?:string|null;options?:string[];sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes`,{method:'POST',body:JSON.stringify(input)});}
|
||||
export function updateInventoryFamilyAttribute(familyId:string,attributeId:string,input:{name?:string;dataType?:InventoryFamilyAttributeDataType;isRequired?:boolean;isActive?:boolean;unit?:string|null;options?:string[]|null;sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes/${attributeId}`,{method:'PATCH',body:JSON.stringify(input)});}
|
||||
export function replaceInventoryFamilyFindings(familyId:string,input:{itemIds:string[];reason:string}){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`,{method:'PUT',body:JSON.stringify(input)});}
|
||||
export function createInventoryStructure(input:{kind:InventoryStructureKind;code?:string|null;name:string;commonName?:string|null;parentId?:string|null;familyId?:string|null;description?:string|null}){return apiRequest<CreatedInventoryStructure>('/inventory-structure',{method:'POST',body:JSON.stringify(input)});}
|
||||
|
||||
+114
-108
@@ -19,11 +19,18 @@ import type {
|
||||
} from '../lib/api';
|
||||
import {
|
||||
createInventoryFamily,
|
||||
createInventoryFamilyAttribute,
|
||||
getInventoryFamilyAttributes,
|
||||
listInventoryFamiliesAdmin,
|
||||
replaceInventoryFamilyFindings,
|
||||
updateInventoryFamily,
|
||||
updateInventoryFamilyAttribute,
|
||||
} from '../lib/inventoryStructureApi';
|
||||
import type {
|
||||
InventoryFamily,
|
||||
InventoryFamilyAttribute,
|
||||
InventoryFamilyAttributeDataType,
|
||||
} from '../lib/inventoryStructureApi';
|
||||
import type { InventoryFamily } from '../lib/inventoryStructureApi';
|
||||
|
||||
const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> = [
|
||||
{ value: 'TEXT', label: 'Texto' },
|
||||
@@ -33,12 +40,12 @@ const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> =
|
||||
{ value: 'DATETIME', label: 'Fecha y hora' },
|
||||
{ value: 'SELECT', label: 'Lista de opciones' },
|
||||
];
|
||||
|
||||
const EMPTY_FINDING_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
|
||||
|
||||
type CanonicalKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
|
||||
type FamilyEditor = InventoryFamily | 'new' | null;
|
||||
type FamilyFindingMode = 'ASSOCIATED' | 'ALL';
|
||||
type TechnicalEditor = InventoryFamilyAttribute | 'new' | null;
|
||||
|
||||
const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string }> = [
|
||||
{ kind: 'EMPRESA', label: 'Empresa', description: 'Maestro independiente. Se vincula temporalmente a un Área.' },
|
||||
@@ -55,11 +62,9 @@ function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | nul
|
||||
const code = kind.toLowerCase();
|
||||
return types.find((type) => type.code.toLowerCase() === code && type.isActive) ?? null;
|
||||
}
|
||||
|
||||
function attributeTypeLabel(value: AssetAttributeDataType) {
|
||||
function attributeTypeLabel(value: AssetAttributeDataType | InventoryFamilyAttributeDataType) {
|
||||
return ATTRIBUTE_TYPES.find((item) => item.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
function attributeCodeFromName(value: string) {
|
||||
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim()
|
||||
.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 80);
|
||||
@@ -68,6 +73,7 @@ function attributeCodeFromName(value: string) {
|
||||
export function AssetTypesPage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canManage = hasPermission('asset_types.manage');
|
||||
const canManageFindings = hasPermission('finding_catalog.manage');
|
||||
const [types, setTypes] = useState<AssetType[]>([]);
|
||||
const [families, setFamilies] = useState<InventoryFamily[]>([]);
|
||||
const [findingCatalog, setFindingCatalog] = useState<FindingAdminCatalog>(EMPTY_FINDING_CATALOG);
|
||||
@@ -81,11 +87,22 @@ export function AssetTypesPage() {
|
||||
const [familyEditor, setFamilyEditor] = useState<FamilyEditor>(null);
|
||||
const [familyLevel, setFamilyLevel] = useState<'INSTALLATION' | 'SUBINSTALLATION'>('INSTALLATION');
|
||||
const [familyName, setFamilyName] = useState('');
|
||||
const [familyParentId, setFamilyParentId] = useState('');
|
||||
const [familyParentIds, setFamilyParentIds] = useState<Set<string>>(new Set());
|
||||
const [familyActive, setFamilyActive] = useState(true);
|
||||
const [familyFindingIds, setFamilyFindingIds] = useState<Set<string>>(new Set());
|
||||
const [familyFindingMode, setFamilyFindingMode] = useState<FamilyFindingMode>('ASSOCIATED');
|
||||
const [familyFindingSearch, setFamilyFindingSearch] = useState('');
|
||||
const [technicalAttributes, setTechnicalAttributes] = useState<InventoryFamilyAttribute[]>([]);
|
||||
const [technicalLoading, setTechnicalLoading] = useState(false);
|
||||
const [technicalEditor, setTechnicalEditor] = useState<TechnicalEditor>(null);
|
||||
const [technicalCode, setTechnicalCode] = useState('');
|
||||
const [technicalName, setTechnicalName] = useState('');
|
||||
const [technicalType, setTechnicalType] = useState<InventoryFamilyAttributeDataType>('TEXT');
|
||||
const [technicalRequired, setTechnicalRequired] = useState(false);
|
||||
const [technicalActive, setTechnicalActive] = useState(true);
|
||||
const [technicalUnit, setTechnicalUnit] = useState('');
|
||||
const [technicalOptions, setTechnicalOptions] = useState('');
|
||||
const [technicalOrder, setTechnicalOrder] = useState(0);
|
||||
|
||||
const [attributeEditor, setAttributeEditor] = useState<AssetAttributeDefinition | 'new' | null>(null);
|
||||
const [attributeCode, setAttributeCode] = useState('');
|
||||
@@ -99,15 +116,10 @@ export function AssetTypesPage() {
|
||||
|
||||
const load = async () => {
|
||||
const [loadedTypes, loadedFamilies, loadedFindingCatalog] = await Promise.all([
|
||||
listAssetTypes(),
|
||||
listInventoryFamiliesAdmin(),
|
||||
getFindingCatalogAdmin(),
|
||||
listAssetTypes(), listInventoryFamiliesAdmin(), getFindingCatalogAdmin(),
|
||||
]);
|
||||
setTypes(loadedTypes);
|
||||
setFamilies(loadedFamilies);
|
||||
setFindingCatalog(loadedFindingCatalog);
|
||||
setTypes(loadedTypes); setFamilies(loadedFamilies); setFindingCatalog(loadedFindingCatalog);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
||||
}, []);
|
||||
@@ -116,7 +128,7 @@ export function AssetTypesPage() {
|
||||
const installationFamilies = useMemo(() => families.filter((item) => item.level === 'INSTALLATION'), [families]);
|
||||
const subinstallationFamilies = useMemo(() => families.filter((item) => item.level === 'SUBINSTALLATION'), [families]);
|
||||
const visibleSubinstallationFamilies = useMemo(() => subinstallationFamilies.filter((family) =>
|
||||
family.isActive !== false && (!selectedInstallationFamilyId || family.parentFamilyId === selectedInstallationFamilyId),
|
||||
family.isActive !== false && (!selectedInstallationFamilyId || family.parentFamilyIds.includes(selectedInstallationFamilyId)),
|
||||
), [subinstallationFamilies, selectedInstallationFamilyId]);
|
||||
|
||||
const activeFindingCategoryIds = useMemo(() => new Set(
|
||||
@@ -138,48 +150,79 @@ export function AssetTypesPage() {
|
||||
|
||||
const openNewFamily = (level: 'INSTALLATION' | 'SUBINSTALLATION') => {
|
||||
setFamilyEditor('new'); setFamilyLevel(level); setFamilyName('');
|
||||
setFamilyParentId(level === 'SUBINSTALLATION' ? selectedInstallationFamilyId : '');
|
||||
setFamilyParentIds(new Set(level === 'SUBINSTALLATION' && selectedInstallationFamilyId ? [selectedInstallationFamilyId] : []));
|
||||
setFamilyActive(true); setFamilyFindingIds(new Set()); setFamilyFindingMode('ASSOCIATED'); setFamilyFindingSearch('');
|
||||
setError(''); setSuccess('');
|
||||
setTechnicalAttributes([]); setTechnicalEditor(null); setError(''); setSuccess('');
|
||||
};
|
||||
const openFamily = (family: InventoryFamily) => {
|
||||
const openFamily = async (family: InventoryFamily) => {
|
||||
if (family.level === 'INSTALLATION') setSelectedInstallationFamilyId(family.id);
|
||||
setFamilyEditor(family); setFamilyLevel(family.level); setFamilyName(family.name);
|
||||
setFamilyParentId(family.parentFamilyId ?? ''); setFamilyActive(family.isActive !== false);
|
||||
setFamilyParentIds(new Set(family.parentFamilyIds)); setFamilyActive(family.isActive !== false);
|
||||
setFamilyFindingIds(new Set(family.findingItemIds ?? [])); setFamilyFindingMode('ASSOCIATED'); setFamilyFindingSearch('');
|
||||
setError(''); setSuccess('');
|
||||
setTechnicalEditor(null); setTechnicalLoading(true); setError(''); setSuccess('');
|
||||
try { setTechnicalAttributes((await getInventoryFamilyAttributes(family.id)).items); }
|
||||
catch (requestError) { setError(errorMessage(requestError)); setTechnicalAttributes([]); }
|
||||
finally { setTechnicalLoading(false); }
|
||||
};
|
||||
|
||||
const toggleParentCompatibility = (id: string) => setFamilyParentIds((current) => {
|
||||
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
|
||||
});
|
||||
const toggleFamilyFinding = (id: string) => setFamilyFindingIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
|
||||
});
|
||||
|
||||
const saveFamily = async (event: FormEvent) => {
|
||||
event.preventDefault(); setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
if (familyEditor === 'new') {
|
||||
await createInventoryFamily({
|
||||
level: familyLevel,
|
||||
name: familyName.trim(),
|
||||
parentFamilyId: familyLevel === 'SUBINSTALLATION' ? familyParentId : null,
|
||||
const created = await createInventoryFamily({
|
||||
level: familyLevel, name: familyName.trim(),
|
||||
parentFamilyIds: familyLevel === 'SUBINSTALLATION' ? [...familyParentIds] : [],
|
||||
});
|
||||
setSuccess(`${familyLevel === 'INSTALLATION' ? 'Tipo de Instalación' : 'Tipo de Subinstalación'} creado.`);
|
||||
} else if (familyEditor) {
|
||||
await load(); await openFamily(created); return;
|
||||
}
|
||||
if (familyEditor) {
|
||||
await updateInventoryFamily(familyEditor.id, {
|
||||
name: familyName.trim(),
|
||||
parentFamilyId: familyLevel === 'SUBINSTALLATION' ? familyParentId : null,
|
||||
name: familyName.trim(), parentFamilyIds: familyLevel === 'SUBINSTALLATION' ? [...familyParentIds] : [],
|
||||
isActive: familyActive,
|
||||
});
|
||||
await replaceInventoryFamilyFindings(familyEditor.id, {
|
||||
itemIds: [...familyFindingIds],
|
||||
reason: 'Actualización desde Configuración de Inventarios',
|
||||
if (canManageFindings) await replaceInventoryFamilyFindings(familyEditor.id, {
|
||||
itemIds: [...familyFindingIds], reason: 'Actualización desde Configuración de Inventarios F6',
|
||||
});
|
||||
setSuccess('Clasificación y Hallazgos asociados actualizados.');
|
||||
setSuccess('Clasificación actualizada.'); await load(); setFamilyEditor(null);
|
||||
}
|
||||
await load();
|
||||
setFamilyEditor(null);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const openTechnicalAttribute = (attribute: InventoryFamilyAttribute | 'new') => {
|
||||
setTechnicalEditor(attribute); setError(''); setSuccess('');
|
||||
if (attribute === 'new') {
|
||||
setTechnicalCode(''); setTechnicalName(''); setTechnicalType('TEXT'); setTechnicalRequired(false);
|
||||
setTechnicalActive(true); setTechnicalUnit(''); setTechnicalOptions(''); setTechnicalOrder(technicalAttributes.length);
|
||||
} else {
|
||||
setTechnicalCode(attribute.code); setTechnicalName(attribute.name); setTechnicalType(attribute.dataType);
|
||||
setTechnicalRequired(attribute.isRequired); setTechnicalActive(attribute.isActive); setTechnicalUnit(attribute.unit ?? '');
|
||||
setTechnicalOptions(attribute.options?.join('\n') ?? ''); setTechnicalOrder(attribute.sortOrder);
|
||||
}
|
||||
};
|
||||
const saveTechnicalAttribute = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!familyEditor || familyEditor === 'new' || !technicalEditor) return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
const options = technicalOptions.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
|
||||
try {
|
||||
if (technicalEditor === 'new') await createInventoryFamilyAttribute(familyEditor.id, {
|
||||
code: technicalCode, name: technicalName.trim(), dataType: technicalType, isRequired: technicalRequired,
|
||||
unit: technicalUnit || null, ...(technicalType === 'SELECT' ? { options } : {}), sortOrder: technicalOrder,
|
||||
});
|
||||
else await updateInventoryFamilyAttribute(familyEditor.id,technicalEditor.id,{
|
||||
name:technicalName.trim(),dataType:technicalType,isRequired:technicalRequired,isActive:technicalActive,
|
||||
unit:technicalUnit || null,options:technicalType==='SELECT' ? options : null,sortOrder:technicalOrder,
|
||||
});
|
||||
setTechnicalAttributes((await getInventoryFamilyAttributes(familyEditor.id)).items);
|
||||
setTechnicalEditor(null); setSuccess('Campo técnico actualizado.'); await load();
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
@@ -188,100 +231,63 @@ export function AssetTypesPage() {
|
||||
setAttributeEditor(attribute); setError(''); setSuccess('');
|
||||
if (attribute === 'new') {
|
||||
setAttributeCode(''); setAttributeName(''); setAttributeType('TEXT'); setAttributeRequired(false);
|
||||
setAttributeActive(true); setAttributeUnit(''); setAttributeOptions('');
|
||||
setAttributeOrder(selectedType?.attributes.length ?? 0);
|
||||
setAttributeActive(true); setAttributeUnit(''); setAttributeOptions(''); setAttributeOrder(selectedType?.attributes.length ?? 0);
|
||||
} else {
|
||||
setAttributeCode(attribute.code); setAttributeName(attribute.name); setAttributeType(attribute.dataType);
|
||||
setAttributeRequired(attribute.isRequired); setAttributeActive(attribute.isActive);
|
||||
setAttributeUnit(attribute.unit ?? ''); setAttributeOptions(attribute.options?.join('\n') ?? '');
|
||||
setAttributeOrder(attribute.sortOrder);
|
||||
setAttributeRequired(attribute.isRequired); setAttributeActive(attribute.isActive); setAttributeUnit(attribute.unit ?? '');
|
||||
setAttributeOptions(attribute.options?.join('\n') ?? ''); setAttributeOrder(attribute.sortOrder);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAttribute = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedType || !attributeEditor) return;
|
||||
event.preventDefault(); if (!selectedType || !attributeEditor) return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
const options = attributeOptions.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
|
||||
try {
|
||||
if (attributeEditor === 'new') {
|
||||
await createAssetAttribute(selectedType.id, {
|
||||
code: attributeCode,
|
||||
name: attributeName,
|
||||
dataType: attributeType,
|
||||
isRequired: attributeRequired,
|
||||
unit: attributeUnit || null,
|
||||
...(attributeType === 'SELECT' ? { options } : {}),
|
||||
sortOrder: attributeOrder,
|
||||
});
|
||||
setSuccess(`Nueva columna agregada a ${LEVELS.find((item) => item.kind === selectedKind)?.label}.`);
|
||||
} else {
|
||||
await updateAssetAttribute(selectedType.id, attributeEditor.id, {
|
||||
name: attributeName,
|
||||
dataType: attributeType,
|
||||
isRequired: attributeRequired,
|
||||
isActive: attributeActive,
|
||||
unit: attributeUnit || null,
|
||||
options: attributeType === 'SELECT' ? options : null,
|
||||
sortOrder: attributeOrder,
|
||||
});
|
||||
setSuccess('Columna actualizada.');
|
||||
}
|
||||
await load();
|
||||
setAttributeEditor(null);
|
||||
if (attributeEditor === 'new') await createAssetAttribute(selectedType.id, {
|
||||
code: attributeCode,name: attributeName,dataType: attributeType,isRequired: attributeRequired,
|
||||
unit: attributeUnit || null,...(attributeType === 'SELECT' ? { options } : {}),sortOrder: attributeOrder,
|
||||
});
|
||||
else await updateAssetAttribute(selectedType.id, attributeEditor.id, {
|
||||
name: attributeName,dataType: attributeType,isRequired: attributeRequired,isActive: attributeActive,
|
||||
unit: attributeUnit || null,options: attributeType === 'SELECT' ? options : null,sortOrder: attributeOrder,
|
||||
});
|
||||
await load(); setAttributeEditor(null); setSuccess('Campo general actualizado.');
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando configuración de Inventarios…" />;
|
||||
|
||||
return <section className="inventory-config-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<span className="eyebrow">ADMINISTRACIÓN</span>
|
||||
<h1>Configuración de Inventarios</h1>
|
||||
<p>Administrá la estructura, los tipos de Instalación/Subinstalación, sus Hallazgos y las columnas de información.</p>
|
||||
</div>
|
||||
{canManage && <Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>}
|
||||
</div>
|
||||
<div className="page-heading"><div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Configuración de Inventarios</h1><p>Administrá clasificaciones, compatibilidades, Hallazgos y campos técnicos desde un único lugar.</p></div>{canManage && <Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>}</div>
|
||||
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
||||
|
||||
<div className="panel" style={{ marginBottom: 18 }}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">MODELO VIGENTE</span><h2>Estructura física</h2><p className="section-copy">Cada nivel tiene un único padre estructural. Empresa queda como maestro independiente y se relaciona con Área.</p></div></div>
|
||||
<div className="asset-browser-levels" aria-label="Estructura de Inventarios">
|
||||
<div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i>›</i>
|
||||
<div><span>2</span><strong>Área</strong><small>dentro del Departamento</small></div><i>›</i>
|
||||
<div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i>›</i>
|
||||
<div><span>4</span><strong>Instalación</strong><small>clasificación técnica</small></div><i>›</i>
|
||||
<div><span>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div>
|
||||
</div>
|
||||
<div className="temporal-notice" style={{ marginTop: 14 }}><Icon name="users" /><p><strong>Empresa:</strong> maestro independiente. Cambiar la operadora de un Área no mueve ni reescribe la estructura física.</p></div>
|
||||
</div>
|
||||
<div className="panel" style={{ marginBottom: 18 }}><div className="panel-heading"><div><span className="eyebrow">MODELO F6</span><h2>Estructura física fija</h2><p className="section-copy">La clasificación describe qué es cada Instalación/Subinstalación, pero nunca altera su nivel jerárquico.</p></div></div><div className="asset-browser-levels" aria-label="Estructura de Inventarios"><div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i>›</i><div><span>2</span><strong>Área</strong><small>dentro del Departamento</small></div><i>›</i><div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i>›</i><div><span>4</span><strong>Instalación</strong><small>clasificación técnica</small></div><i>›</i><div><span>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div></div><div className="temporal-notice" style={{ marginTop: 14 }}><Icon name="users" /><p><strong>Empresa:</strong> maestro independiente; su relación con Área no reescribe el árbol físico.</p></div></div>
|
||||
|
||||
<div className="dashboard-grid" style={{ alignItems: 'start' }}>
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Instalación</h2><p className="section-copy">Tocá una Instalación para filtrar sus Subinstalaciones y editar sus Hallazgos.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('INSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div>
|
||||
<div className="attribute-list">{installationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className={`attribute-card ${selectedInstallationFamilyId === family.id ? 'active' : ''}`} key={family.id} onClick={() => openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.findingCount ?? 0} Hallazgos asociados</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>
|
||||
</article>
|
||||
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Subinstalación</h2><p className="section-copy">Mostrá todas o sólo las que pertenecen a una Instalación.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('SUBINSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div>
|
||||
<label className="field" style={{ marginBottom: 14 }}><span>Filtrar por tipo de Instalación</span><SearchableSelect value={selectedInstallationFamilyId} onChange={(event) => setSelectedInstallationFamilyId(event.target.value)}><option value="">Todas las Instalaciones</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}</SearchableSelect></label>
|
||||
{visibleSubinstallationFamilies.length === 0 ? <div className="inline-empty">{selectedInstallationFamilyId ? 'Esta Instalación todavía no tiene tipos de Subinstalación asociados.' : 'No hay tipos de Subinstalación configurados.'}</div> : <div className="attribute-list" style={{ maxHeight: 560, overflow: 'auto' }}>{visibleSubinstallationFamilies.map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilyName ?? 'Sin Instalación padre'} · {family.findingCount ?? 0} Hallazgos asociados</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>}
|
||||
</article>
|
||||
<article className="panel"><div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Instalación</h2><p className="section-copy">Tocá uno para editar Hallazgos, campos técnicos y ver sus Subinstalaciones compatibles.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('INSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div><div className="attribute-list">{installationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className={`attribute-card ${selectedInstallationFamilyId === family.id ? 'active' : ''}`} key={family.id} onClick={() => void openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.findingCount ?? 0} Hallazgos · {family.technicalAttributeCount ?? 0} campos técnicos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div></article>
|
||||
<article className="panel"><div className="panel-heading"><div><span className="eyebrow">COMPATIBILIDAD</span><h2>Tipos de Subinstalación</h2><p className="section-copy">Una misma clasificación puede ser válida para varias Instalaciones.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('SUBINSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div><label className="field" style={{ marginBottom: 14 }}><span>Filtrar por tipo de Instalación</span><SearchableSelect value={selectedInstallationFamilyId} onChange={(event) => setSelectedInstallationFamilyId(event.target.value)}><option value="">Todas las Instalaciones</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}</SearchableSelect></label>{visibleSubinstallationFamilies.length === 0 ? <div className="inline-empty">{selectedInstallationFamilyId ? 'No hay tipos compatibles con esta Instalación.' : 'No hay tipos de Subinstalación configurados.'}</div> : <div className="attribute-list" style={{ maxHeight: 560, overflow: 'auto' }}>{visibleSubinstallationFamilies.map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => void openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilies.map((parent) => parent.name).join(' · ') || 'Sin compatibilidades'} · {family.findingCount ?? 0} Hallazgos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>}</article>
|
||||
</div>
|
||||
|
||||
<article className="panel" style={{ marginTop: 18 }}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">COLUMNAS / CAMPOS</span><h2>Información configurable</h2><p className="section-copy">Agregá las columnas que deben completarse para cada nivel. No modifica la jerarquía.</p></div>{canManage && selectedType && <button className="button primary" onClick={() => openAttribute('new')}><Icon name="plus" />Nueva columna</button>}</div>
|
||||
<div className="quick-view-row" style={{ marginBottom: 16 }}>{LEVELS.map((level) => <button type="button" key={level.kind} className={selectedKind === level.kind ? 'active' : ''} onClick={() => { setSelectedKind(level.kind); setAttributeEditor(null); }}>{level.label}</button>)}</div>
|
||||
<p className="section-copy">{LEVELS.find((item) => item.kind === selectedKind)?.description}</p>
|
||||
{!selectedType ? <Alert>Este nivel todavía no tiene un tipo maestro activo.</Alert> : selectedType.attributes.length === 0 ? <div className="inline-empty">No hay columnas adicionales configuradas para {LEVELS.find((item) => item.kind === selectedKind)?.label}.</div> : <div className="attribute-list">{selectedType.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>}
|
||||
</article>
|
||||
<article className="panel" style={{ marginTop: 18 }}><div className="panel-heading"><div><span className="eyebrow">CAMPOS GENERALES DEL NIVEL</span><h2>Información estructural</h2><p className="section-copy">Usá estos campos sólo cuando correspondan a todo el nivel. Marca, potencia, capacidad u otros datos técnicos deben configurarse dentro de cada clasificación.</p></div>{canManage && selectedType && <button className="button primary" onClick={() => openAttribute('new')}><Icon name="plus" />Nuevo campo general</button>}</div><div className="quick-view-row" style={{ marginBottom: 16 }}>{LEVELS.map((level) => <button type="button" key={level.kind} className={selectedKind === level.kind ? 'active' : ''} onClick={() => { setSelectedKind(level.kind); setAttributeEditor(null); }}>{level.label}</button>)}</div><p className="section-copy">{LEVELS.find((item) => item.kind === selectedKind)?.description}</p>{!selectedType ? <Alert>Este nivel todavía no tiene un tipo maestro activo.</Alert> : selectedType.attributes.length === 0 ? <div className="inline-empty">No hay campos generales para este nivel.</div> : <div className="attribute-list">{selectedType.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>}</article>
|
||||
|
||||
<div className="panel" style={{ marginTop: 18 }}><div className="panel-heading"><div><span className="eyebrow">HALLAZGOS</span><h2>Relación por clasificación</h2><p className="section-copy">Los Hallazgos se pueden revisar y editar directamente dentro de cada tipo. El Catálogo completo sigue disponible para una administración masiva.</p></div><Link className="button secondary" to="/admin/finding-catalog">Abrir Catálogo de hallazgos <Icon name="chevron" /></Link></div></div>
|
||||
{familyEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveFamily}><div className="drawer-heading"><div><span className="eyebrow">CLASIFICACIÓN TÉCNICA</span><h2>{familyEditor === 'new' ? 'Nueva clasificación' : familyName}</h2></div><button type="button" className="icon-button" onClick={() => setFamilyEditor(null)}>×</button></div><div className="catalog-editor-fields">
|
||||
{familyEditor === 'new' && <label className="field"><span>Nivel</span><SearchableSelect value={familyLevel} onChange={(event) => { setFamilyLevel(event.target.value as 'INSTALLATION' | 'SUBINSTALLATION'); setFamilyParentIds(new Set()); }}><option value="INSTALLATION">Instalación</option><option value="SUBINSTALLATION">Subinstalación</option></SearchableSelect></label>}
|
||||
<label className="field"><span>Nombre</span><input value={familyName} onChange={(event) => setFamilyName(event.target.value)} maxLength={240} required /></label>
|
||||
{familyLevel === 'SUBINSTALLATION' && <div className="field"><span>Tipos de Instalación compatibles <em>{familyParentIds.size} seleccionados</em></span><div className="finding-selection-list" style={{ maxHeight: 220, overflow: 'auto' }}>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label className="finding-selection-row" key={family.id}><input type="checkbox" checked={familyParentIds.has(family.id)} onChange={() => toggleParentCompatibility(family.id)} /><span><strong>{family.name}</strong><small>{family.code}</small></span></label>)}</div></div>}
|
||||
{familyEditor !== 'new' && <>
|
||||
<div className="temporal-notice"><Icon name="alert" /><p><strong>{familyFindingIds.size} Hallazgos asociados.</strong> La APK sólo verá estos Hallazgos para esta clasificación, además de OTROS.</p></div>
|
||||
<div className="quick-view-row"><button type="button" className={familyFindingMode === 'ASSOCIATED' ? 'active' : ''} onClick={() => setFamilyFindingMode('ASSOCIATED')}>Asociados ({familyFindingIds.size})</button>{canManageFindings && <button type="button" className={familyFindingMode === 'ALL' ? 'active' : ''} onClick={() => setFamilyFindingMode('ALL')}>Agregar o quitar</button>}</div>
|
||||
<label className="field"><span>Buscar Hallazgo</span><input value={familyFindingSearch} onChange={(event) => setFamilyFindingSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>
|
||||
{visibleFamilyFindings.length === 0 ? <div className="inline-empty">{familyFindingMode === 'ASSOCIATED' ? 'No hay Hallazgos asociados.' : 'No hay coincidencias.'}</div> : <div className="finding-selection-list" style={{ maxHeight: 280, overflow: 'auto' }}>{visibleFamilyFindings.map((item) => familyFindingMode === 'ALL' ? <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={familyFindingIds.has(item.id)} onChange={() => toggleFamilyFinding(item.id)} /><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></label> : <div className="finding-selection-row" key={item.id}><span className="asset-symbol"><Icon name="check" size={14} /></span><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></div>)}</div>}
|
||||
|
||||
{familyEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveFamily}><div className="drawer-heading"><div><span className="eyebrow">CLASIFICACIÓN DE INVENTARIO</span><h2>{familyEditor === 'new' ? 'Nuevo tipo' : 'Editar tipo'}</h2></div><button type="button" className="icon-button" onClick={() => setFamilyEditor(null)}>×</button></div><div className="catalog-editor-fields">{familyEditor === 'new' && <label className="field"><span>Nivel</span><SearchableSelect value={familyLevel} onChange={(event) => { setFamilyLevel(event.target.value as 'INSTALLATION' | 'SUBINSTALLATION'); setFamilyParentId(''); }}><option value="INSTALLATION">Instalación</option><option value="SUBINSTALLATION">Subinstalación</option></SearchableSelect></label>}<label className="field"><span>Nombre</span><input value={familyName} onChange={(event) => setFamilyName(event.target.value)} maxLength={240} required placeholder={familyLevel === 'INSTALLATION' ? 'Ej.: Estación, Planta…' : 'Ej.: Tanque, Bomba…'} /></label>{familyLevel === 'SUBINSTALLATION' && <label className="field"><span>Tipo de Instalación padre</span><SearchableSelect value={familyParentId} onChange={(event) => setFamilyParentId(event.target.value)} required><option value="">Seleccionar…</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option value={family.id} key={family.id}>{family.name}</option>)}</SearchableSelect></label>}{familyEditor !== 'new' && <><div className="temporal-notice"><Icon name="alert" /><p><strong>{familyFindingIds.size} Hallazgos asociados.</strong> Se guardan junto con esta clasificación.</p></div><div className="quick-view-row"><button type="button" className={familyFindingMode === 'ASSOCIATED' ? 'active' : ''} onClick={() => setFamilyFindingMode('ASSOCIATED')}>Asociados ({familyFindingIds.size})</button><button type="button" className={familyFindingMode === 'ALL' ? 'active' : ''} onClick={() => setFamilyFindingMode('ALL')}>Agregar o quitar</button></div><label className="field"><span>Buscar Hallazgo</span><input value={familyFindingSearch} onChange={(event) => setFamilyFindingSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>{visibleFamilyFindings.length === 0 ? <div className="inline-empty">{familyFindingMode === 'ASSOCIATED' ? 'No hay Hallazgos asociados. Tocá “Agregar o quitar” para vincularlos.' : 'No hay Hallazgos que coincidan con la búsqueda.'}</div> : <div className="finding-selection-list" style={{ maxHeight: 300, overflow: 'auto' }}>{visibleFamilyFindings.map((item) => familyFindingMode === 'ALL' ? <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={familyFindingIds.has(item.id)} onChange={() => toggleFamilyFinding(item.id)} /><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></label> : <div className="finding-selection-row" key={item.id}><span className="asset-symbol"><Icon name="check" size={14} /></span><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></div>)}</div>}<Link className="button secondary" to={`/admin/finding-catalog?familyId=${familyEditor.id}`}>Abrir en Catálogo completo <Icon name="chevron" /></Link><label className="check-row"><input type="checkbox" checked={familyActive} onChange={(event) => setFamilyActive(event.target.checked)} /><span><strong>Tipo disponible</strong><small>Al desactivarlo deja de ofrecerse en nuevas altas.</small></span></label></>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setFamilyEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !familyName.trim() || (familyLevel === 'SUBINSTALLATION' && !familyParentId)}>{saving ? 'Guardando…' : 'Guardar tipo y Hallazgos'}</button></div></form></div>}
|
||||
<div className="panel-heading" style={{ marginTop: 10 }}><div><span className="eyebrow">DATOS DEL RUBRO</span><h3>Campos técnicos</h3><p className="section-copy">Sólo aparecen cuando un elemento usa esta clasificación.</p></div>{canManage && !technicalEditor && <button type="button" className="button secondary" onClick={() => openTechnicalAttribute('new')}><Icon name="plus" />Nuevo campo</button>}</div>
|
||||
{technicalLoading ? <LoadingBlock label="Cargando campos técnicos…" /> : technicalAttributes.length === 0 && !technicalEditor ? <div className="inline-empty">No hay campos técnicos definidos.</div> : !technicalEditor && <div className="attribute-list">{technicalAttributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openTechnicalAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span>{attribute.isRequired && <span className="tag">Obligatorio</span>}<Icon name="chevron" /></button>)}</div>}
|
||||
{technicalEditor && <div className="panel" style={{ padding: 14 }}><div className="panel-heading"><div><strong>{technicalEditor === 'new' ? 'Nuevo campo técnico' : 'Editar campo técnico'}</strong></div><button type="button" className="icon-button" onClick={() => setTechnicalEditor(null)}>×</button></div><label className="field"><span>Nombre</span><input value={technicalName} onChange={(event) => { setTechnicalName(event.target.value); if (technicalEditor === 'new') setTechnicalCode(attributeCodeFromName(event.target.value)); }} required /></label><label className="field"><span>Código interno</span><input value={technicalCode} onChange={(event) => setTechnicalCode(event.target.value.toLowerCase())} disabled={technicalEditor !== 'new'} pattern="[a-z][a-z0-9_]*" required /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={technicalType} onChange={(event) => setTechnicalType(event.target.value as InventoryFamilyAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>{technicalType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={4} value={technicalOptions} onChange={(event) => setTechnicalOptions(event.target.value)} placeholder="Una por línea" required /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={technicalUnit} onChange={(event) => setTechnicalUnit(event.target.value)} /></label><label className="field"><span>Orden</span><input type="number" min={0} max={10000} value={technicalOrder} onChange={(event) => setTechnicalOrder(Number(event.target.value))} /></label><label className="check-row"><input type="checkbox" checked={technicalRequired} onChange={(event) => setTechnicalRequired(event.target.checked)} /><span><strong>Obligatorio</strong><small>Debe completarse para esta clasificación.</small></span></label>{technicalEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={technicalActive} onChange={(event) => setTechnicalActive(event.target.checked)} /><span><strong>Campo activo</strong><small>Desactivarlo conserva datos existentes.</small></span></label>}<div className="form-actions"><button type="button" className="button secondary" onClick={() => setTechnicalEditor(null)}>Cancelar</button><button type="button" className="button primary" disabled={saving || !technicalName.trim() || !technicalCode || (technicalType === 'SELECT' && !technicalOptions.trim())} onClick={(event) => void saveTechnicalAttribute(event as unknown as FormEvent)}>{saving ? 'Guardando…' : 'Guardar campo'}</button></div></div>}
|
||||
<Link className="button secondary" to={`/admin/finding-catalog?familyId=${familyEditor.id}`}>Abrir Catálogo completo <Icon name="chevron" /></Link>
|
||||
<label className="check-row"><input type="checkbox" checked={familyActive} onChange={(event) => setFamilyActive(event.target.checked)} /><span><strong>Clasificación disponible</strong><small>Al desactivarla deja de ofrecerse en nuevas altas.</small></span></label>
|
||||
</>}
|
||||
</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setFamilyEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !familyName.trim() || (familyLevel === 'SUBINSTALLATION' && familyParentIds.size === 0)}>{saving ? 'Guardando…' : familyEditor === 'new' ? 'Crear clasificación' : 'Guardar clasificación'}</button></div></form></div>}
|
||||
|
||||
{attributeEditor && selectedType && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveAttribute}><div className="drawer-heading"><div><span className="eyebrow">COLUMNA DE {LEVELS.find((item) => item.kind === selectedKind)?.label.toUpperCase()}</span><h2>{attributeEditor === 'new' ? 'Nueva columna' : 'Editar columna'}</h2></div><button type="button" className="icon-button" onClick={() => setAttributeEditor(null)}>×</button></div><div className="catalog-editor-fields"><label className="field"><span>Nombre visible</span><input value={attributeName} onChange={(event) => { setAttributeName(event.target.value); if (attributeEditor === 'new') setAttributeCode(attributeCodeFromName(event.target.value)); }} maxLength={160} required /></label><label className="field"><span>Código interno</span><input value={attributeCode} onChange={(event) => setAttributeCode(event.target.value.toLowerCase())} disabled={attributeEditor !== 'new'} maxLength={80} required pattern="[a-z][a-z0-9_]*" /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={attributeType} onChange={(event) => setAttributeType(event.target.value as AssetAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}</SearchableSelect></label>{attributeType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={5} value={attributeOptions} onChange={(event) => setAttributeOptions(event.target.value)} placeholder="Una opción por línea" /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={attributeUnit} onChange={(event) => setAttributeUnit(event.target.value)} maxLength={40} /></label><label className="field"><span>Orden</span><input type="number" value={attributeOrder} onChange={(event) => setAttributeOrder(Number(event.target.value))} min={0} max={10000} /></label><label className="check-row"><input type="checkbox" checked={attributeRequired} onChange={(event) => setAttributeRequired(event.target.checked)} /><span><strong>Campo obligatorio</strong><small>Debe completarse cuando se registra este nivel.</small></span></label>{attributeEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={attributeActive} onChange={(event) => setAttributeActive(event.target.checked)} /><span><strong>Columna activa</strong><small>Desactivarla conserva los datos actuales.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setAttributeEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !attributeName.trim() || !attributeCode}>{saving ? 'Guardando…' : 'Guardar columna'}</button></div></form></div>}
|
||||
{attributeEditor && selectedType && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveAttribute}><div className="drawer-heading"><div><span className="eyebrow">CAMPO GENERAL DE {LEVELS.find((item) => item.kind === selectedKind)?.label.toUpperCase()}</span><h2>{attributeEditor === 'new' ? 'Nuevo campo' : 'Editar campo'}</h2></div><button type="button" className="icon-button" onClick={() => setAttributeEditor(null)}>×</button></div><div className="catalog-editor-fields"><label className="field"><span>Nombre visible</span><input value={attributeName} onChange={(event) => { setAttributeName(event.target.value); if (attributeEditor === 'new') setAttributeCode(attributeCodeFromName(event.target.value)); }} maxLength={160} required /></label><label className="field"><span>Código interno</span><input value={attributeCode} onChange={(event) => setAttributeCode(event.target.value.toLowerCase())} disabled={attributeEditor !== 'new'} maxLength={80} required pattern="[a-z][a-z0-9_]*" /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={attributeType} onChange={(event) => setAttributeType(event.target.value as AssetAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}</SearchableSelect></label>{attributeType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={5} value={attributeOptions} onChange={(event) => setAttributeOptions(event.target.value)} placeholder="Una opción por línea" /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={attributeUnit} onChange={(event) => setAttributeUnit(event.target.value)} maxLength={40} /></label><label className="field"><span>Orden</span><input type="number" value={attributeOrder} onChange={(event) => setAttributeOrder(Number(event.target.value))} min={0} max={10000} /></label><label className="check-row"><input type="checkbox" checked={attributeRequired} onChange={(event) => setAttributeRequired(event.target.checked)} /><span><strong>Campo obligatorio</strong><small>Debe completarse cuando se registra este nivel.</small></span></label>{attributeEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={attributeActive} onChange={(event) => setAttributeActive(event.target.checked)} /><span><strong>Campo activo</strong><small>Desactivarlo conserva los datos actuales.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setAttributeEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !attributeName.trim() || !attributeCode}>{saving ? 'Guardando…' : 'Guardar campo'}</button></div></form></div>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -26,24 +26,13 @@ const KINDS: Array<{ kind: InventoryStructureKind; label: string }> = [
|
||||
{ kind: 'SUBINSTALACION', label: 'Subinstalación' },
|
||||
{ kind: 'EMPRESA', label: 'Empresa' },
|
||||
];
|
||||
|
||||
const childKindByParentType: Record<string, InventoryStructureKind | undefined> = {
|
||||
departamento: 'AREA',
|
||||
area: 'YACIMIENTO',
|
||||
yacimiento: 'INSTALACION',
|
||||
instalacion: 'SUBINSTALACION',
|
||||
departamento: 'AREA', area: 'YACIMIENTO', yacimiento: 'INSTALACION', instalacion: 'SUBINSTALACION',
|
||||
};
|
||||
|
||||
const parentLabelByKind: Partial<Record<InventoryStructureKind,string>> = {
|
||||
AREA:'Departamento',
|
||||
YACIMIENTO:'Área',
|
||||
INSTALACION:'Yacimiento',
|
||||
SUBINSTALACION:'Instalación',
|
||||
AREA:'Departamento', YACIMIENTO:'Área', INSTALACION:'Yacimiento', SUBINSTALACION:'Instalación',
|
||||
};
|
||||
|
||||
function kindLabel(kind: InventoryStructureKind) {
|
||||
return KINDS.find((item) => item.kind===kind)?.label ?? kind;
|
||||
}
|
||||
function kindLabel(kind: InventoryStructureKind) { return KINDS.find((item) => item.kind===kind)?.label ?? kind; }
|
||||
|
||||
export function InventoryCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -65,17 +54,14 @@ export function InventoryCreatePage() {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getInventoryStructureOptions()
|
||||
.then(async (loaded) => {
|
||||
setOptions(loaded);
|
||||
if (contextParentId) {
|
||||
const parent = await getAsset(contextParentId);
|
||||
const inferred = childKindByParentType[parent.type.code.toLowerCase()];
|
||||
if (inferred) { setKind(inferred); setParentId(parent.id); }
|
||||
}
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
getInventoryStructureOptions().then(async (loaded) => {
|
||||
setOptions(loaded);
|
||||
if (contextParentId) {
|
||||
const parent = await getAsset(contextParentId);
|
||||
const inferred = childKindByParentType[parent.type.code.toLowerCase()];
|
||||
if (inferred) { setKind(inferred); setParentId(parent.id); }
|
||||
}
|
||||
}).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
||||
}, [contextParentId]);
|
||||
|
||||
const requiresParent = Boolean(parentLabelByKind[kind]);
|
||||
@@ -85,12 +71,10 @@ export function InventoryCreatePage() {
|
||||
useEffect(() => {
|
||||
if (!requiresParent) { setParents([]); setParentId(''); return; }
|
||||
const timer = window.setTimeout(() => {
|
||||
listInventoryStructureParents(kind,parentSearch)
|
||||
.then((loaded) => {
|
||||
setParents(loaded);
|
||||
if (contextParentId && loaded.some((item) => item.id===contextParentId)) setParentId(contextParentId);
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)));
|
||||
listInventoryStructureParents(kind,parentSearch).then((loaded) => {
|
||||
setParents(loaded);
|
||||
if (contextParentId && loaded.some((item) => item.id===contextParentId)) setParentId(contextParentId);
|
||||
}).catch((requestError) => setError(errorMessage(requestError)));
|
||||
},150);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [kind,parentSearch,contextParentId,requiresParent]);
|
||||
@@ -101,7 +85,7 @@ export function InventoryCreatePage() {
|
||||
if (kind==='INSTALACION') return options.installationFamilies;
|
||||
if (kind==='SUBINSTALACION') {
|
||||
const parentFamilyId=selectedParent?.inventoryFamily?.id;
|
||||
return parentFamilyId ? options.subinstallationFamilies.filter((item)=>item.parentFamilyId===parentFamilyId) : [];
|
||||
return parentFamilyId ? options.subinstallationFamilies.filter((item)=>item.parentFamilyIds.includes(parentFamilyId)) : [];
|
||||
}
|
||||
return [];
|
||||
},[options,kind,selectedParent]);
|
||||
@@ -111,7 +95,6 @@ export function InventoryCreatePage() {
|
||||
if (!familyId) { setFamilyFindings(null); return; }
|
||||
getInventoryFamilyFindings(familyId).then(setFamilyFindings).catch((requestError)=>setError(errorMessage(requestError)));
|
||||
},[familyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requiresFamily) setFamilyId('');
|
||||
if (kind==='SUBINSTALACION' && familyId && !families.some((item)=>item.id===familyId)) setFamilyId('');
|
||||
@@ -120,7 +103,6 @@ export function InventoryCreatePage() {
|
||||
const changeKind=(next:InventoryStructureKind) => {
|
||||
setKind(next); setParentId(''); setParentSearch(''); setFamilyId(''); setFamilyFindings(null); setError('');
|
||||
};
|
||||
|
||||
const save=async(event:FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (requiresParent && !parentId) { setError(`Seleccioná ${parentLabel}.`); return; }
|
||||
@@ -137,46 +119,15 @@ export function InventoryCreatePage() {
|
||||
};
|
||||
|
||||
if (loading) return <LoadingBlock label="Preparando alta…" />;
|
||||
|
||||
return <section className="narrow-section asset-detail-page">
|
||||
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del Inventario"><Link to="/inventarios">Inventarios</Link><span>›</span><strong>Nuevo registro</strong></nav>
|
||||
<div className="page-heading asset-editor-heading">
|
||||
<div><span className="eyebrow">CARGA MANUAL</span><h1>Nuevo registro</h1><p>Elegí qué querés crear, dónde va y su nombre. El resto es opcional.</p></div>
|
||||
</div>
|
||||
<div className="page-heading asset-editor-heading"><div><span className="eyebrow">CARGA MANUAL</span><h1>Nuevo registro</h1><p>Para crear sólo necesitás ubicación, clasificación cuando corresponda y nombre. DH genera el código automáticamente.</p></div></div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<form className="panel form-panel" onSubmit={save}>
|
||||
<div className="form-section">
|
||||
<div><h2>1. Tipo de registro</h2><p className="section-copy">La estructura física es Departamento → Área → Yacimiento → Instalación → Subinstalación. Empresa es un maestro independiente.</p></div>
|
||||
<label className="field"><span>Crear</span><select value={kind} onChange={(event)=>changeKind(event.target.value as InventoryStructureKind)}>{KINDS.map((item)=><option key={item.kind} value={item.kind}>{item.label}</option>)}</select></label>
|
||||
</div>
|
||||
|
||||
{requiresParent && <div className="form-section">
|
||||
<div><h2>2. Ubicación</h2><p className="section-copy">Elegí el {parentLabel.toLowerCase()} al que pertenece.</p></div>
|
||||
<label className="field"><span>Buscar {parentLabel.toLowerCase()}</span><input value={parentSearch} onChange={(event)=>setParentSearch(event.target.value)} placeholder={`Buscar ${parentLabel.toLowerCase()}…`} /></label>
|
||||
<label className="field"><span>{parentLabel} <em>obligatorio</em></span><select value={parentId} onChange={(event)=>{setParentId(event.target.value);setFamilyId('');}} required><option value="">Seleccionar…</option>{parents.map((parent)=><option key={parent.id} value={parent.id}>{parent.name} · {parent.code}</option>)}</select></label>
|
||||
</div>}
|
||||
|
||||
{requiresFamily && <div className="form-section">
|
||||
<div><h2>{requiresParent ? '3' : '2'}. Clasificación</h2><p className="section-copy">Define qué tipo de elemento es y qué Hallazgos le corresponden.</p></div>
|
||||
{kind==='SUBINSTALACION' && !selectedParent?.inventoryFamily
|
||||
? <Alert>La Instalación elegida todavía no tiene clasificación. Asignala antes de crear una Subinstalación.</Alert>
|
||||
: <label className="field"><span>Tipo de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span><select value={familyId} onChange={(event)=>setFamilyId(event.target.value)} required><option value="">Seleccionar…</option>{families.map((family)=><option key={family.id} value={family.id}>{family.name}</option>)}</select></label>}
|
||||
{selectedFamily && <div className="context-create-banner"><Icon name="alert" /><div><strong>{selectedFamily.name}</strong><span>{familyFindings ? `${familyFindings.count} Hallazgo${familyFindings.count===1?'':'s'} asociado${familyFindings.count===1?'':'s'}` : 'Cargando Hallazgos asociados…'}</span>{familyFindings && familyFindings.items.length>0 && <ul style={{margin:'8px 0 0',paddingLeft:18}}>{familyFindings.items.slice(0,6).map((item)=><li key={item.id}>{item.title}</li>)}{familyFindings.items.length>6 && <li>+ {familyFindings.items.length-6} más</li>}</ul>}</div></div>}
|
||||
</div>}
|
||||
|
||||
<div className="form-section">
|
||||
<div><h2>{requiresFamily ? '4' : requiresParent ? '3' : '2'}. Identificación</h2><p className="section-copy">Para comenzar sólo necesitamos un nombre claro.</p></div>
|
||||
<label className="field"><span>Nombre <em>obligatorio</em></span><input autoFocus value={name} onChange={(event)=>setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} /></label>
|
||||
<details style={{marginTop:12}}><summary style={{cursor:'pointer',fontWeight:700}}>Datos opcionales</summary><div style={{marginTop:14}}>
|
||||
<div className="form-grid">
|
||||
<label className="field"><span>Código DH</span><input value={code} onChange={(event)=>setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Se genera solo si lo dejás vacío" /></label>
|
||||
<label className="field"><span>Nombre habitual</span><input value={commonName} onChange={(event)=>setCommonName(event.target.value)} maxLength={200} /></label>
|
||||
</div>
|
||||
<label className="field"><span>Descripción</span><textarea value={description} onChange={(event)=>setDescription(event.target.value)} rows={2} maxLength={4000} /></label>
|
||||
</div></details>
|
||||
</div>
|
||||
|
||||
<div className="form-section"><div><h2>1. ¿Qué querés crear?</h2><p className="section-copy">Departamento → Área → Yacimiento → Instalación → Subinstalación. Empresa queda fuera del árbol físico.</p></div><label className="field"><span>Tipo</span><select value={kind} onChange={(event)=>changeKind(event.target.value as InventoryStructureKind)}>{KINDS.map((item)=><option key={item.kind} value={item.kind}>{item.label}</option>)}</select></label></div>
|
||||
{requiresParent && <div className="form-section"><div><h2>2. Ubicación</h2><p className="section-copy">Elegí el {parentLabel.toLowerCase()} concreto.</p></div><label className="field"><span>Buscar {parentLabel.toLowerCase()}</span><input value={parentSearch} onChange={(event)=>setParentSearch(event.target.value)} placeholder={`Buscar ${parentLabel.toLowerCase()}…`} /></label><label className="field"><span>{parentLabel} <em>obligatorio</em></span><select value={parentId} onChange={(event)=>{setParentId(event.target.value);setFamilyId('');}} required><option value="">Seleccionar…</option>{parents.map((parent)=><option key={parent.id} value={parent.id}>{parent.name} · {parent.code}</option>)}</select></label></div>}
|
||||
{requiresFamily && <div className="form-section"><div><h2>3. Clasificación técnica</h2><p className="section-copy">Define los datos técnicos y Hallazgos aplicables. En Subinstalaciones sólo aparecen clasificaciones compatibles con la Instalación elegida.</p></div>{kind==='SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación elegida todavía no tiene clasificación técnica.</Alert> : <label className="field"><span>Tipo de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span><select value={familyId} onChange={(event)=>setFamilyId(event.target.value)} required><option value="">Seleccionar…</option>{families.map((family)=><option key={family.id} value={family.id}>{family.name}</option>)}</select></label>}{kind==='SUBINSTALACION' && selectedParent?.inventoryFamily && families.length===0 && <Alert>No hay tipos de Subinstalación compatibles con {selectedParent.inventoryFamily.name}. Configuralos primero en Configuración de Inventarios.</Alert>}{selectedFamily && <div className="context-create-banner"><Icon name="alert" /><div><strong>{selectedFamily.name}</strong><span>{familyFindings ? `${familyFindings.count} Hallazgo${familyFindings.count===1?'':'s'} asociado${familyFindings.count===1?'':'s'}` : 'Cargando Hallazgos asociados…'}</span>{familyFindings && familyFindings.items.length>0 && <ul style={{margin:'8px 0 0',paddingLeft:18}}>{familyFindings.items.slice(0,6).map((item)=><li key={item.id}>{item.title}</li>)}{familyFindings.items.length>6 && <li>+ {familyFindings.items.length-6} más</li>}</ul>}</div></div>}</div>}
|
||||
<div className="form-section"><div><h2>{requiresFamily ? '4' : requiresParent ? '3' : '2'}. Nombre</h2><p className="section-copy">Los datos técnicos se completan después, en la ficha del elemento.</p></div><label className="field"><span>Nombre <em>obligatorio</em></span><input autoFocus value={name} onChange={(event)=>setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} /></label><details style={{marginTop:12}}><summary style={{cursor:'pointer',fontWeight:700}}>Datos opcionales</summary><div style={{marginTop:14}}><div className="form-grid"><label className="field"><span>Código DH</span><input value={code} onChange={(event)=>setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Dejalo vacío para generar automáticamente" /></label><label className="field"><span>Nombre habitual</span><input value={commonName} onChange={(event)=>setCommonName(event.target.value)} maxLength={200} /></label></div><label className="field"><span>Descripción</span><textarea value={description} onChange={(event)=>setDescription(event.target.value)} rows={2} maxLength={4000} /></label></div></details></div>
|
||||
<div className="form-actions"><Link className="button secondary" to="/inventarios">Cancelar</Link><button className="button primary" disabled={saving || !name.trim() || (requiresParent && !parentId) || (requiresFamily && !familyId)}><Icon name="check" />{saving?'Creando…':`Crear ${kindLabel(kind)}`}</button></div>
|
||||
</form>
|
||||
</section>;
|
||||
|
||||
Reference in New Issue
Block a user