feat(inventory): add function catalog and act data
DH V2 CI / WEB · typecheck, build (push) Successful in 45s
Production dependency audit / API · production dependencies (push) Successful in 27s
DH V2 CI / API · typecheck, tests, build (push) Successful in 1m15s
Production dependency audit / WEB · production dependencies (push) Successful in 15s
DH V2 CI / Docker / migrations / production images (push) Successful in 2m21s
DH V2 CI / Promote verified main to deploy (push) Successful in 9s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 6m25s
DH V2 CI / WEB · typecheck, build (push) Successful in 45s
Production dependency audit / API · production dependencies (push) Successful in 27s
DH V2 CI / API · typecheck, tests, build (push) Successful in 1m15s
Production dependency audit / WEB · production dependencies (push) Successful in 15s
DH V2 CI / Docker / migrations / production images (push) Successful in 2m21s
DH V2 CI / Promote verified main to deploy (push) Successful in 9s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 6m25s
This commit is contained in:
@@ -168,3 +168,11 @@ Esta candidata requiere conexión. No implementa trabajo offline ni cola persist
|
|||||||
- El Yacimiento elegido queda congelado como alcance de la Inspección y del Acta.
|
- El Yacimiento elegido queda congelado como alcance de la Inspección y del Acta.
|
||||||
- Los Hallazgos pueden registrarse sobre ese Yacimiento, sus Instalaciones o sus Subinstalaciones; nunca fuera de esa rama.
|
- Los Hallazgos pueden registrarse sobre ese Yacimiento, sus Instalaciones o sus Subinstalaciones; nunca fuera de esa rama.
|
||||||
- Al abrir una Inspección que ya está en curso, la APK entra directamente a Actas y Hallazgos.
|
- Al abrir una Inspección que ya está en curso, la APK entra directamente a Actas y Hallazgos.
|
||||||
|
|
||||||
|
|
||||||
|
## F6.18 · catálogo de funciones e identidad documental
|
||||||
|
|
||||||
|
- `versionName`: **0.19.14**; `versionCode`: **42**.
|
||||||
|
- Nombre habitual obligatorio para Instalaciones/Subinstalaciones; Nombre técnico opcional.
|
||||||
|
- Función seleccionable desde el catálogo común; `Otro` permite incorporar una función faltante y conservarla para usos posteriores.
|
||||||
|
- La cola offline conserva `functionId` o `newFunctionName` hasta sincronizar.
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ android {
|
|||||||
applicationId = "com.korexlabs.dhinspeccion"
|
applicationId = "com.korexlabs.dhinspeccion"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 41
|
versionCode = 42
|
||||||
versionName = "0.19.13"
|
versionName = "0.19.14"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import com.korexlabs.dhinspeccion.data.FieldFindingOptionsResponse
|
|||||||
import com.korexlabs.dhinspeccion.data.FieldFindingsRepository
|
import com.korexlabs.dhinspeccion.data.FieldFindingsRepository
|
||||||
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
|
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
|
||||||
import com.korexlabs.dhinspeccion.data.FieldType
|
import com.korexlabs.dhinspeccion.data.FieldType
|
||||||
|
import com.korexlabs.dhinspeccion.data.InventoryFunctionOption
|
||||||
import com.korexlabs.dhinspeccion.data.MobileActClosure
|
import com.korexlabs.dhinspeccion.data.MobileActClosure
|
||||||
import com.korexlabs.dhinspeccion.data.MobileActClosureHeader
|
import com.korexlabs.dhinspeccion.data.MobileActClosureHeader
|
||||||
import com.korexlabs.dhinspeccion.data.MobileActDetail
|
import com.korexlabs.dhinspeccion.data.MobileActDetail
|
||||||
@@ -72,6 +73,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
private set
|
private set
|
||||||
var fieldTypes: List<FieldType> by mutableStateOf(emptyList())
|
var fieldTypes: List<FieldType> by mutableStateOf(emptyList())
|
||||||
private set
|
private set
|
||||||
|
var inventoryFunctions: List<InventoryFunctionOption> by mutableStateOf(emptyList())
|
||||||
|
private set
|
||||||
var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null)
|
var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@@ -296,6 +299,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadInventoryFunctions() {
|
||||||
|
launchBusy {
|
||||||
|
inventoryFunctions = repository.inventoryFunctions().data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun loadFieldTypes(parentId: String? = null) {
|
fun loadFieldTypes(parentId: String? = null) {
|
||||||
val currentVisit = visit ?: return
|
val currentVisit = visit ?: return
|
||||||
launchBusy {
|
launchBusy {
|
||||||
@@ -372,13 +381,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
type: FieldType,
|
type: FieldType,
|
||||||
parentId: String?,
|
parentId: String?,
|
||||||
familyId: String?,
|
familyId: String?,
|
||||||
name: String,
|
name: String?,
|
||||||
commonName: String?,
|
commonName: String?,
|
||||||
attributes: Map<String, Any?>,
|
attributes: Map<String, Any?>,
|
||||||
latitude: Double,
|
latitude: Double,
|
||||||
longitude: Double,
|
longitude: Double,
|
||||||
accuracyM: Double?,
|
accuracyM: Double?,
|
||||||
description: String? = null,
|
description: String? = null,
|
||||||
|
functionId: String? = null,
|
||||||
|
newFunctionName: String? = null,
|
||||||
) {
|
) {
|
||||||
val visitId = visit?.id ?: return
|
val visitId = visit?.id ?: return
|
||||||
if (visit?.status != "IN_PROGRESS") {
|
if (visit?.status != "IN_PROGRESS") {
|
||||||
@@ -389,6 +400,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
error = "Elegí el Tipo de instalación o subinstalación correspondiente."
|
error = "Elegí el Tipo de instalación o subinstalación correspondiente."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (commonName.isNullOrBlank()) {
|
||||||
|
error = "Completá el Nombre habitual. Es obligatorio y se utilizará en el Acta."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (functionId != null && !newFunctionName.isNullOrBlank()) {
|
||||||
|
error = "Elegí una función del catálogo o cargá Otra, no ambas."
|
||||||
|
return
|
||||||
|
}
|
||||||
launchBusy(mutation = true) {
|
launchBusy(mutation = true) {
|
||||||
val assetId = DhRepository.newOperationId()
|
val assetId = DhRepository.newOperationId()
|
||||||
val capturedAt = Instant.now().toString()
|
val capturedAt = Instant.now().toString()
|
||||||
@@ -397,8 +416,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
typeId = type.id,
|
typeId = type.id,
|
||||||
parentId = parentId,
|
parentId = parentId,
|
||||||
familyId = familyId,
|
familyId = familyId,
|
||||||
name = name.trim(),
|
name = name?.trim()?.takeIf { it.isNotBlank() },
|
||||||
commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
|
commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
|
||||||
|
functionId = functionId,
|
||||||
|
newFunctionName = newFunctionName?.trim()?.takeIf { it.isNotBlank() },
|
||||||
description = description?.trim()?.takeIf { it.isNotBlank() },
|
description = description?.trim()?.takeIf { it.isNotBlank() },
|
||||||
attributes = attributes,
|
attributes = attributes,
|
||||||
deviceLatitude = FieldCoordinates.latitude(latitude),
|
deviceLatitude = FieldCoordinates.latitude(latitude),
|
||||||
@@ -410,7 +431,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val localAsset = FieldInventoryItem(
|
val localAsset = FieldInventoryItem(
|
||||||
id = assetId,
|
id = assetId,
|
||||||
code = "PEND-${assetId.take(6).uppercase()}",
|
code = "PEND-${assetId.take(6).uppercase()}",
|
||||||
name = request.name,
|
name = request.name ?: request.commonName ?: "Sin denominación",
|
||||||
commonName = request.commonName,
|
commonName = request.commonName,
|
||||||
informationStatus = "DRAFT",
|
informationStatus = "DRAFT",
|
||||||
dataOrigin = "FIELD_SURVEY",
|
dataOrigin = "FIELD_SURVEY",
|
||||||
|
|||||||
@@ -256,14 +256,27 @@ data class FieldAssetDetail(
|
|||||||
val capture: CaptureStatus = CaptureStatus(),
|
val capture: CaptureStatus = CaptureStatus(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class InventoryFunctionOption(
|
||||||
|
val id: String,
|
||||||
|
val code: String,
|
||||||
|
val name: String,
|
||||||
|
val description: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class InventoryFunctionListResponse(
|
||||||
|
val data: List<InventoryFunctionOption> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
data class CreateFieldInventoryRequest(
|
data class CreateFieldInventoryRequest(
|
||||||
val clientGeneratedId: String? = null,
|
val clientGeneratedId: String? = null,
|
||||||
val typeId: String,
|
val typeId: String,
|
||||||
val parentId: String? = null,
|
val parentId: String? = null,
|
||||||
val familyId: String? = null,
|
val familyId: String? = null,
|
||||||
val code: String? = null,
|
val code: String? = null,
|
||||||
val name: String,
|
val name: String? = null,
|
||||||
val commonName: String? = null,
|
val commonName: String? = null,
|
||||||
|
val functionId: String? = null,
|
||||||
|
val newFunctionName: String? = null,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
val discoveryNotes: String? = null,
|
val discoveryNotes: String? = null,
|
||||||
val attributes: Map<String, Any?>,
|
val attributes: Map<String, Any?>,
|
||||||
@@ -336,6 +349,11 @@ interface DhApi {
|
|||||||
@Query("parentId") parentId: String? = null,
|
@Query("parentId") parentId: String? = null,
|
||||||
): FieldTypeResponse
|
): FieldTypeResponse
|
||||||
|
|
||||||
|
@GET("inventory-functions")
|
||||||
|
suspend fun inventoryFunctions(
|
||||||
|
@Header("Authorization") authorization: String,
|
||||||
|
): InventoryFunctionListResponse
|
||||||
|
|
||||||
@POST("inspection-visits/{visitId}/field-inventory/{assetId}/select")
|
@POST("inspection-visits/{visitId}/field-inventory/{assetId}/select")
|
||||||
suspend fun selectFieldAsset(
|
suspend fun selectFieldAsset(
|
||||||
@Header("Authorization") authorization: String,
|
@Header("Authorization") authorization: String,
|
||||||
@@ -461,6 +479,7 @@ class DhRepository(context: Context) {
|
|||||||
private val visitAdapter = moshi.adapter(VisitDetail::class.java)
|
private val visitAdapter = moshi.adapter(VisitDetail::class.java)
|
||||||
private val inventoryAdapter = moshi.adapter(FieldInventoryListResponse::class.java)
|
private val inventoryAdapter = moshi.adapter(FieldInventoryListResponse::class.java)
|
||||||
private val fieldTypeAdapter = moshi.adapter(FieldTypeResponse::class.java)
|
private val fieldTypeAdapter = moshi.adapter(FieldTypeResponse::class.java)
|
||||||
|
private val inventoryFunctionAdapter = moshi.adapter(InventoryFunctionListResponse::class.java)
|
||||||
private val api: DhApi = Retrofit.Builder()
|
private val api: DhApi = Retrofit.Builder()
|
||||||
.baseUrl(BuildConfig.API_BASE_URL)
|
.baseUrl(BuildConfig.API_BASE_URL)
|
||||||
.client(OkHttpClient.Builder().build())
|
.client(OkHttpClient.Builder().build())
|
||||||
@@ -521,6 +540,11 @@ class DhRepository(context: Context) {
|
|||||||
authorized { session -> api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId) }
|
authorized { session -> api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun inventoryFunctions(): InventoryFunctionListResponse =
|
||||||
|
cached("inventory-functions", inventoryFunctionAdapter) {
|
||||||
|
authorized { session -> api.inventoryFunctions("Bearer ${session.accessToken}") }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session ->
|
suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session ->
|
||||||
api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId)
|
api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId)
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -205,6 +205,8 @@ class OfflineMutationQueue(private val context: Context) {
|
|||||||
.put("code", request.code)
|
.put("code", request.code)
|
||||||
.put("name", request.name)
|
.put("name", request.name)
|
||||||
.put("commonName", request.commonName)
|
.put("commonName", request.commonName)
|
||||||
|
.put("functionId", request.functionId)
|
||||||
|
.put("newFunctionName", request.newFunctionName)
|
||||||
.put("description", request.description)
|
.put("description", request.description)
|
||||||
.put("discoveryNotes", request.discoveryNotes)
|
.put("discoveryNotes", request.discoveryNotes)
|
||||||
.put("attributes", JSONObject(request.attributes))
|
.put("attributes", JSONObject(request.attributes))
|
||||||
@@ -424,8 +426,10 @@ class OfflineMutationQueue(private val context: Context) {
|
|||||||
parentId = payload.optNullableString("parentId"),
|
parentId = payload.optNullableString("parentId"),
|
||||||
familyId = payload.optNullableString("familyId"),
|
familyId = payload.optNullableString("familyId"),
|
||||||
code = payload.optNullableString("code"),
|
code = payload.optNullableString("code"),
|
||||||
name = payload.getString("name"),
|
name = payload.optNullableString("name"),
|
||||||
commonName = payload.optNullableString("commonName"),
|
commonName = payload.optNullableString("commonName"),
|
||||||
|
functionId = payload.optNullableString("functionId"),
|
||||||
|
newFunctionName = payload.optNullableString("newFunctionName"),
|
||||||
description = payload.optNullableString("description"),
|
description = payload.optNullableString("description"),
|
||||||
discoveryNotes = payload.optNullableString("discoveryNotes"),
|
discoveryNotes = payload.optNullableString("discoveryNotes"),
|
||||||
attributes = jsonObjectToMap(payload.getJSONObject("attributes")),
|
attributes = jsonObjectToMap(payload.getJSONObject("attributes")),
|
||||||
|
|||||||
@@ -287,6 +287,11 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
var name by rememberSaveable(visit.id) { mutableStateOf("") }
|
var name by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||||
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
|
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||||
var description by rememberSaveable(visit.id) { mutableStateOf("") }
|
var description by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||||
|
var selectedFunctionId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||||
|
var functionSearch by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||||
|
var functionExpanded by remember { mutableStateOf(false) }
|
||||||
|
var useNewFunction by rememberSaveable(visit.id) { mutableStateOf(false) }
|
||||||
|
var newFunctionName by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||||
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||||
var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||||
var familySearch by rememberSaveable(visit.id) { mutableStateOf("") }
|
var familySearch by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||||
@@ -304,6 +309,11 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
name = ""
|
name = ""
|
||||||
commonName = ""
|
commonName = ""
|
||||||
description = ""
|
description = ""
|
||||||
|
selectedFunctionId = null
|
||||||
|
functionSearch = ""
|
||||||
|
functionExpanded = false
|
||||||
|
useNewFunction = false
|
||||||
|
newFunctionName = ""
|
||||||
selectedTypeId = null
|
selectedTypeId = null
|
||||||
selectedFamilyId = null
|
selectedFamilyId = null
|
||||||
familySearch = ""
|
familySearch = ""
|
||||||
@@ -356,6 +366,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
|
|
||||||
LaunchedEffect(visit.id) {
|
LaunchedEffect(visit.id) {
|
||||||
model.searchInventory("", visit.scopeAsset?.id)
|
model.searchInventory("", visit.scopeAsset?.id)
|
||||||
|
model.loadInventoryFunctions()
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(modeName, parentSearch) {
|
LaunchedEffect(modeName, parentSearch) {
|
||||||
@@ -393,7 +404,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
|
|
||||||
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
|
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
|
||||||
val editableAttributes = selectedType?.attributes.orEmpty().filterNot {
|
val editableAttributes = selectedType?.attributes.orEmpty().filterNot {
|
||||||
selectedType?.familyRequired == true && it.code == "tipo_instalacion"
|
it.code == "campo_funcion" || (selectedType?.familyRequired == true && it.code == "tipo_instalacion")
|
||||||
}
|
}
|
||||||
val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId }
|
val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId }
|
||||||
val filteredFamilies = remember(selectedType, familySearch) {
|
val filteredFamilies = remember(selectedType, familySearch) {
|
||||||
@@ -403,6 +414,14 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val selectedFunction = model.inventoryFunctions.firstOrNull { it.id == selectedFunctionId }
|
||||||
|
val filteredFunctions = remember(model.inventoryFunctions, functionSearch) {
|
||||||
|
val needle = modernNormalize(functionSearch)
|
||||||
|
model.inventoryFunctions.filter { item ->
|
||||||
|
needle.isBlank() || modernNormalize("${item.code} ${item.name}").contains(needle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
|
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
|
||||||
var pendingPhotoGeo by remember { mutableStateOf<ModernGeoSnapshot?>(null) }
|
var pendingPhotoGeo by remember { mutableStateOf<ModernGeoSnapshot?>(null) }
|
||||||
|
|
||||||
@@ -443,8 +462,10 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
type = type,
|
type = type,
|
||||||
parentId = effectiveParent,
|
parentId = effectiveParent,
|
||||||
familyId = selectedFamilyId,
|
familyId = selectedFamilyId,
|
||||||
name = name,
|
name = name.takeIf { it.isNotBlank() },
|
||||||
commonName = commonName,
|
commonName = commonName,
|
||||||
|
functionId = if (useNewFunction) null else selectedFunctionId,
|
||||||
|
newFunctionName = if (useNewFunction) newFunctionName else null,
|
||||||
description = description,
|
description = description,
|
||||||
attributes = buildModernAttributes(type, attributeValues),
|
attributes = buildModernAttributes(type, attributeValues),
|
||||||
latitude = geo.latitude,
|
latitude = geo.latitude,
|
||||||
@@ -823,24 +844,98 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
}
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = name,
|
value = commonName,
|
||||||
onValueChange = { name = it },
|
onValueChange = { commonName = it },
|
||||||
label = { Text("Nombre técnico *") },
|
label = { Text("Nombre habitual *") },
|
||||||
placeholder = { Text("Denominación técnica") },
|
supportingText = { Text("Es el nombre que se mostrará en el Acta.") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = commonName,
|
value = name,
|
||||||
onValueChange = { commonName = it },
|
onValueChange = { name = it },
|
||||||
label = { Text("Nombre habitual (opcional)") },
|
label = { Text("Nombre técnico (opcional)") },
|
||||||
|
placeholder = { Text("Denominación técnica, si corresponde") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = functionExpanded && !model.busy,
|
||||||
|
onExpandedChange = {
|
||||||
|
if (!model.busy) {
|
||||||
|
functionExpanded = it
|
||||||
|
if (it) functionSearch = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = if (functionExpanded) functionSearch else if (useNewFunction) "Otra función" else selectedFunction?.name.orEmpty(),
|
||||||
|
onValueChange = { functionSearch = it; functionExpanded = true },
|
||||||
|
label = { Text("Función (opcional)") },
|
||||||
|
placeholder = { Text("Buscar función") },
|
||||||
|
leadingIcon = { Icon(Icons.Filled.Search, null) },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = functionExpanded) },
|
||||||
|
enabled = !model.busy,
|
||||||
|
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = functionExpanded && !model.busy,
|
||||||
|
onDismissRequest = { functionExpanded = false; functionSearch = "" },
|
||||||
|
modifier = Modifier.heightIn(max = 280.dp),
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Sin función") },
|
||||||
|
onClick = {
|
||||||
|
selectedFunctionId = null
|
||||||
|
useNewFunction = false
|
||||||
|
newFunctionName = ""
|
||||||
|
functionExpanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
filteredFunctions.forEach { item ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(item.name) },
|
||||||
|
trailingIcon = { if (!useNewFunction && selectedFunctionId == item.id) Icon(Icons.Filled.CheckCircle, "Seleccionada") },
|
||||||
|
onClick = {
|
||||||
|
selectedFunctionId = item.id
|
||||||
|
useNewFunction = false
|
||||||
|
newFunctionName = ""
|
||||||
|
functionExpanded = false
|
||||||
|
functionSearch = ""
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Otro · agregar función") },
|
||||||
|
trailingIcon = { if (useNewFunction) Icon(Icons.Filled.CheckCircle, "Seleccionada") },
|
||||||
|
onClick = {
|
||||||
|
selectedFunctionId = null
|
||||||
|
useNewFunction = true
|
||||||
|
functionExpanded = false
|
||||||
|
functionSearch = ""
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (useNewFunction) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newFunctionName,
|
||||||
|
onValueChange = { newFunctionName = it },
|
||||||
|
label = { Text("Nueva función *") },
|
||||||
|
supportingText = { Text("Se incorporará al Catálogo de funciones al sincronizar.") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = description,
|
value = description,
|
||||||
onValueChange = { description = it },
|
onValueChange = { description = it },
|
||||||
@@ -896,7 +991,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
|
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
|
||||||
Button(
|
Button(
|
||||||
onClick = { requestCreate() },
|
onClick = { requestCreate() },
|
||||||
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
|
enabled = selectedType != null && commonName.isNotBlank() && (!useNewFunction || newFunctionName.isNotBlank()) && attributesReady && familyReady && !model.busy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.CameraAlt, null)
|
Icon(Icons.Filled.CameraAlt, null)
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
|
|||||||
@Test
|
@Test
|
||||||
fun debugBuildKeepsSeparateApplicationIdentity() {
|
fun debugBuildKeepsSeparateApplicationIdentity() {
|
||||||
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
|
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
|
||||||
assertEquals(41, BuildConfig.VERSION_CODE)
|
assertEquals(42, BuildConfig.VERSION_CODE)
|
||||||
assertEquals("0.19.13-debug", BuildConfig.VERSION_NAME)
|
assertEquals("0.19.14-debug", BuildConfig.VERSION_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.29.0-17",
|
"version": "0.29.0-18",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.29.0-17",
|
"version": "0.29.0-18",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.29.0-17",
|
"version": "0.29.0-18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { InventoryFamilyCatalogService } from './inventory-family-catalog.servic
|
|||||||
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller';
|
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller';
|
||||||
import { InventoryTechnicalValuesService } from './inventory-technical-values.service';
|
import { InventoryTechnicalValuesService } from './inventory-technical-values.service';
|
||||||
import { InventoryFunctionService } from './inventory-function.service';
|
import { InventoryFunctionService } from './inventory-function.service';
|
||||||
|
import { InventoryFunctionController } from './inventory-function.controller';
|
||||||
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
|
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
|
||||||
import { InventoryMergeService } from './inventory-merge.service';
|
import { InventoryMergeService } from './inventory-merge.service';
|
||||||
import { ActivityDossierService } from './activity-dossier.service';
|
import { ActivityDossierService } from './activity-dossier.service';
|
||||||
@@ -47,6 +48,7 @@ import { InventoryBrowserService } from './inventory-browser.service';
|
|||||||
InventoryStructureController,
|
InventoryStructureController,
|
||||||
InventoryFamilyCatalogController,
|
InventoryFamilyCatalogController,
|
||||||
InventoryTechnicalValuesController,
|
InventoryTechnicalValuesController,
|
||||||
|
InventoryFunctionController,
|
||||||
InventoryBrowserController,
|
InventoryBrowserController,
|
||||||
InventoryMergeController,
|
InventoryMergeController,
|
||||||
FieldInventoryMergeController,
|
FieldInventoryMergeController,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import type { ListFieldDiscoveriesQueryDto } from './dto/list-field-discoveries-
|
|||||||
import type { MatchFieldDiscoveryDto, RejectFieldDiscoveryDto, ReviewFieldDiscoveryDto } from './dto/review-field-discovery.dto';
|
import type { MatchFieldDiscoveryDto, RejectFieldDiscoveryDto, ReviewFieldDiscoveryDto } from './dto/review-field-discovery.dto';
|
||||||
import type { ChangeAssetContextDto } from './dto/change-asset-context.dto';
|
import type { ChangeAssetContextDto } from './dto/change-asset-context.dto';
|
||||||
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
|
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
|
||||||
|
import { InventoryFunctionService } from './inventory-function.service';
|
||||||
|
|
||||||
export interface AssetListItem {
|
export interface AssetListItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -97,9 +98,31 @@ export class AssetsService {
|
|||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
private readonly history: AssetHistoryService,
|
private readonly history: AssetHistoryService,
|
||||||
|
private readonly inventoryFunctions: InventoryFunctionService,
|
||||||
private readonly fieldDiscoveryInspectionLinks: FieldDiscoveryInspectionLinkService,
|
private readonly fieldDiscoveryInspectionLinks: FieldDiscoveryInspectionLinkService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private isTechnicalInventoryType(type: AssetType): boolean {
|
||||||
|
const code = type.code.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
||||||
|
return code === 'instalacion' || code === 'subinstalacion';
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveIdentity(type: AssetType, name: string | null | undefined, commonName: string | null | undefined) {
|
||||||
|
const technicalName = name?.trim() || null;
|
||||||
|
const habitualName = commonName?.trim() || null;
|
||||||
|
if (this.isTechnicalInventoryType(type) && !habitualName) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'ASSET_COMMON_NAME_REQUIRED',
|
||||||
|
message: 'El Nombre habitual es obligatorio para Instalaciones y Subinstalaciones',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const persistedName = technicalName || habitualName;
|
||||||
|
if (!persistedName) {
|
||||||
|
throw new BadRequestException({ code: 'ASSET_NAME_REQUIRED', message: 'Ingresá un nombre para el registro' });
|
||||||
|
}
|
||||||
|
return { technicalName, habitualName, persistedName };
|
||||||
|
}
|
||||||
|
|
||||||
async list(query: ListAssetsQueryDto) {
|
async list(query: ListAssetsQueryDto) {
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
const parameters: unknown[] = [];
|
const parameters: unknown[] = [];
|
||||||
@@ -627,6 +650,7 @@ export class AssetsService {
|
|||||||
try {
|
try {
|
||||||
return await this.dataSource.transaction(async (manager) => {
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
const type = await this.requireActiveType(manager, dto.typeId);
|
const type = await this.requireActiveType(manager, dto.typeId);
|
||||||
|
const identity = this.resolveIdentity(type, dto.name, dto.commonName);
|
||||||
await this.validateParent(manager, type, dto.parentId ?? null, null);
|
await this.validateParent(manager, type, dto.parentId ?? null, null);
|
||||||
await this.validateOperationalAssignment(
|
await this.validateOperationalAssignment(
|
||||||
manager,
|
manager,
|
||||||
@@ -644,8 +668,8 @@ export class AssetsService {
|
|||||||
operationalAreaId: dto.operationalAreaId ?? null,
|
operationalAreaId: dto.operationalAreaId ?? null,
|
||||||
operatorCompanyId: dto.operatorCompanyId ?? null,
|
operatorCompanyId: dto.operatorCompanyId ?? null,
|
||||||
code: dto.code,
|
code: dto.code,
|
||||||
name: dto.name,
|
name: identity.persistedName,
|
||||||
commonName: dto.commonName ?? null,
|
commonName: identity.habitualName,
|
||||||
description: dto.description ?? null,
|
description: dto.description ?? null,
|
||||||
informationStatus: principal.permissions.includes('assets.change_status')
|
informationStatus: principal.permissions.includes('assets.change_status')
|
||||||
? dto.informationStatus
|
? dto.informationStatus
|
||||||
@@ -676,6 +700,9 @@ export class AssetsService {
|
|||||||
principal,
|
principal,
|
||||||
request,
|
request,
|
||||||
);
|
);
|
||||||
|
if (dto.functionId) {
|
||||||
|
await this.inventoryFunctions.assignInitial(manager, asset.id, { functionId: dto.functionId }, principal, request);
|
||||||
|
}
|
||||||
await this.insertInitialContextHistory(manager, asset, versionNumber, principal, request, 'Contexto inicial del registro');
|
await this.insertInitialContextHistory(manager, asset, versionNumber, principal, request, 'Contexto inicial del registro');
|
||||||
const created = await this.loadView(manager, asset.id);
|
const created = await this.loadView(manager, asset.id);
|
||||||
await this.audit.record(
|
await this.audit.record(
|
||||||
@@ -751,6 +778,7 @@ export class AssetsService {
|
|||||||
if (!visit.assigned) throw new ConflictException({ code: 'FIELD_DISCOVERY_INSPECTOR_NOT_ASSIGNED', message: 'El inspector no está asignado a esta inspección' });
|
if (!visit.assigned) throw new ConflictException({ code: 'FIELD_DISCOVERY_INSPECTOR_NOT_ASSIGNED', message: 'El inspector no está asignado a esta inspección' });
|
||||||
|
|
||||||
const type = await this.requireActiveType(manager, dto.typeId);
|
const type = await this.requireActiveType(manager, dto.typeId);
|
||||||
|
const identity = this.resolveIdentity(type, dto.name, dto.commonName);
|
||||||
await this.validateParent(manager, type, dto.parentId, null);
|
await this.validateParent(manager, type, dto.parentId, null);
|
||||||
await this.validateOperationalAssignment(manager, type, dto.parentId, dto.operationalAreaId, dto.operatorCompanyId);
|
await this.validateOperationalAssignment(manager, type, dto.parentId, dto.operationalAreaId, dto.operatorCompanyId);
|
||||||
const definitions = await this.loadDefinitions(manager, type.id);
|
const definitions = await this.loadDefinitions(manager, type.id);
|
||||||
@@ -763,8 +791,8 @@ export class AssetsService {
|
|||||||
operatorCompanyId: dto.operatorCompanyId,
|
operatorCompanyId: dto.operatorCompanyId,
|
||||||
inventoryFamilyId: dto.inventoryFamilyId ?? null,
|
inventoryFamilyId: dto.inventoryFamilyId ?? null,
|
||||||
code: dto.code,
|
code: dto.code,
|
||||||
name: dto.name,
|
name: identity.persistedName,
|
||||||
commonName: dto.commonName ?? null,
|
commonName: identity.habitualName,
|
||||||
description: dto.description ?? null,
|
description: dto.description ?? null,
|
||||||
informationStatus: AssetInformationStatus.DRAFT,
|
informationStatus: AssetInformationStatus.DRAFT,
|
||||||
createdBy: principal.userId,
|
createdBy: principal.userId,
|
||||||
@@ -779,6 +807,13 @@ export class AssetsService {
|
|||||||
await manager.getRepository(Asset).save(asset);
|
await manager.getRepository(Asset).save(asset);
|
||||||
await this.replaceAttributeValues(manager, asset.id, values, principal.userId);
|
await this.replaceAttributeValues(manager, asset.id, values, principal.userId);
|
||||||
const versionNumber = await this.history.capture(manager, asset.id, AssetVersionChangeType.CREATED, principal, request);
|
const versionNumber = await this.history.capture(manager, asset.id, AssetVersionChangeType.CREATED, principal, request);
|
||||||
|
if (dto.functionId || dto.newFunctionName) {
|
||||||
|
await this.inventoryFunctions.assignInitial(
|
||||||
|
manager, asset.id,
|
||||||
|
{ functionId: dto.functionId ?? null, newFunctionName: dto.newFunctionName ?? null },
|
||||||
|
principal, request,
|
||||||
|
);
|
||||||
|
}
|
||||||
await this.insertInitialContextHistory(manager, asset, versionNumber, principal, request, 'Contexto observado en alta de campo');
|
await this.insertInitialContextHistory(manager, asset, versionNumber, principal, request, 'Contexto observado en alta de campo');
|
||||||
const inspectionLinks = await this.fieldDiscoveryInspectionLinks.attach(
|
const inspectionLinks = await this.fieldDiscoveryInspectionLinks.attach(
|
||||||
manager,
|
manager,
|
||||||
@@ -1136,9 +1171,16 @@ export class AssetsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextCommonName = dto.commonName === undefined ? asset.commonName : dto.commonName;
|
||||||
|
if (this.isTechnicalInventoryType(type) && !nextCommonName?.trim()) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'ASSET_COMMON_NAME_REQUIRED',
|
||||||
|
message: 'El Nombre habitual es obligatorio para Instalaciones y Subinstalaciones',
|
||||||
|
});
|
||||||
|
}
|
||||||
if (typeChanged) asset.assetTypeId = type.id;
|
if (typeChanged) asset.assetTypeId = type.id;
|
||||||
if (dto.code !== undefined) asset.code = dto.code;
|
if (dto.code !== undefined) asset.code = dto.code;
|
||||||
if (dto.name !== undefined) asset.name = dto.name;
|
if (dto.name !== undefined) asset.name = dto.name?.trim() || nextCommonName?.trim() || asset.name;
|
||||||
if (dto.commonName !== undefined) asset.commonName = dto.commonName;
|
if (dto.commonName !== undefined) asset.commonName = dto.commonName;
|
||||||
if (dto.parentId !== undefined) asset.parentId = dto.parentId;
|
if (dto.parentId !== undefined) asset.parentId = dto.parentId;
|
||||||
if (dto.operationalAreaId !== undefined) asset.operationalAreaId = dto.operationalAreaId;
|
if (dto.operationalAreaId !== undefined) asset.operationalAreaId = dto.operationalAreaId;
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ export class CreateAssetDto {
|
|||||||
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
|
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
|
||||||
code!: string;
|
code!: string;
|
||||||
|
|
||||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
name!: string;
|
name?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) =>
|
@Transform(({ value }) =>
|
||||||
@@ -35,6 +35,10 @@ export class CreateAssetDto {
|
|||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
commonName?: string | null;
|
commonName?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
functionId?: string | null;
|
||||||
|
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
typeId!: string;
|
typeId!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ export class CreateFieldDiscoveryDto {
|
|||||||
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
|
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
|
||||||
code!: string;
|
code!: string;
|
||||||
|
|
||||||
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
name!: string;
|
name?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
@@ -28,6 +28,16 @@ export class CreateFieldDiscoveryDto {
|
|||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
commonName?: string | null;
|
commonName?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
functionId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(240)
|
||||||
|
newFunctionName?: string | null;
|
||||||
|
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
typeId!: string;
|
typeId!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -64,8 +64,9 @@ export class UpdateInventoryFunctionDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ChangeInventoryFunctionDto {
|
export class ChangeInventoryFunctionDto {
|
||||||
|
@IsOptional()
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
functionId!: string;
|
functionId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsISO8601({ strict: true })
|
@IsISO8601({ strict: true })
|
||||||
|
|||||||
@@ -26,11 +26,10 @@ export class UpdateAssetDto {
|
|||||||
code?: string;
|
code?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
name?: string;
|
name?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) =>
|
@Transform(({ value }) =>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
@@ -98,19 +99,21 @@ export class InventoryFunctionService {
|
|||||||
async changeForAsset(assetId: string, dto: ChangeInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext) {
|
async changeForAsset(assetId: string, dto: ChangeInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext) {
|
||||||
return this.dataSource.transaction(async (manager) => {
|
return this.dataSource.transaction(async (manager) => {
|
||||||
const asset = await this.requireEligibleAsset(manager, assetId, true);
|
const asset = await this.requireEligibleAsset(manager, assetId, true);
|
||||||
const nextFunction = await this.requireFunction(manager, dto.functionId, true);
|
|
||||||
const effectiveAt = dto.effectiveAt ? new Date(dto.effectiveAt) : new Date();
|
const effectiveAt = dto.effectiveAt ? new Date(dto.effectiveAt) : new Date();
|
||||||
if (!Number.isFinite(effectiveAt.getTime())) throw new BadRequestException({ code: 'INVENTORY_FUNCTION_EFFECTIVE_DATE_INVALID', message: 'La fecha efectiva del cambio de función no es válida' });
|
if (!Number.isFinite(effectiveAt.getTime())) throw new BadRequestException({ code: 'INVENTORY_FUNCTION_EFFECTIVE_DATE_INVALID', message: 'La fecha efectiva del cambio de función no es válida' });
|
||||||
if (effectiveAt.getTime() > Date.now() + 60_000) throw new BadRequestException({ code: 'INVENTORY_FUNCTION_FUTURE_DATE_NOT_ALLOWED', message: 'El cambio de función no puede registrarse con fecha futura' });
|
if (effectiveAt.getTime() > Date.now() + 60_000) throw new BadRequestException({ code: 'INVENTORY_FUNCTION_FUTURE_DATE_NOT_ALLOWED', message: 'El cambio de función no puede registrarse con fecha futura' });
|
||||||
|
|
||||||
const current = await this.currentAssignment(manager, assetId, true);
|
const current = await this.currentAssignment(manager, assetId, true);
|
||||||
if (current?.functionId === nextFunction.id) return this.getForAssetWithManager(manager, assetId);
|
const nextFunction = dto.functionId ? await this.requireFunction(manager, dto.functionId, true) : null;
|
||||||
|
if (current?.functionId === nextFunction?.id || (!current && !nextFunction)) return this.getForAssetWithManager(manager, assetId);
|
||||||
if (current && effectiveAt.getTime() <= new Date(current.validFrom).getTime()) {
|
if (current && effectiveAt.getTime() <= new Date(current.validFrom).getTime()) {
|
||||||
throw new ConflictException({ code: 'INVENTORY_FUNCTION_EFFECTIVE_DATE_OVERLAP', message: 'La fecha efectiva debe ser posterior al inicio de la función vigente' });
|
throw new ConflictException({ code: 'INVENTORY_FUNCTION_EFFECTIVE_DATE_OVERLAP', message: 'La fecha efectiva debe ser posterior al inicio de la función vigente' });
|
||||||
}
|
}
|
||||||
if (current) await manager.query(`UPDATE inventory_function_assignments SET valid_until=$2 WHERE id=$1 AND valid_until IS NULL`, [current.id, effectiveAt]);
|
if (current) await manager.query(`UPDATE inventory_function_assignments SET valid_until=$2 WHERE id=$1 AND valid_until IS NULL`, [current.id, effectiveAt]);
|
||||||
|
if (nextFunction) {
|
||||||
await manager.query(`INSERT INTO inventory_function_assignments(asset_id,function_id,valid_from,change_reason,changed_by) VALUES($1,$2,$3,$4,$5)`,
|
await manager.query(`INSERT INTO inventory_function_assignments(asset_id,function_id,valid_from,change_reason,changed_by) VALUES($1,$2,$3,$4,$5)`,
|
||||||
[assetId, nextFunction.id, effectiveAt, dto.reason ?? null, principal.userId]);
|
[assetId, nextFunction.id, effectiveAt, dto.reason ?? null, principal.userId]);
|
||||||
|
}
|
||||||
await manager.query(`UPDATE assets SET updated_by=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [assetId, principal.userId]);
|
await manager.query(`UPDATE assets SET updated_by=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [assetId, principal.userId]);
|
||||||
|
|
||||||
const versionNumber = await this.history.capture(manager, assetId, AssetVersionChangeType.FUNCTION_CHANGED, principal, request);
|
const versionNumber = await this.history.capture(manager, assetId, AssetVersionChangeType.FUNCTION_CHANGED, principal, request);
|
||||||
@@ -119,13 +122,97 @@ export class InventoryFunctionService {
|
|||||||
...administrationAuditContext(principal, request), action: AuditAction.ASSET_FUNCTION_CHANGED,
|
...administrationAuditContext(principal, request), action: AuditAction.ASSET_FUNCTION_CHANGED,
|
||||||
entityType: 'asset', entityId: assetId,
|
entityType: 'asset', entityId: assetId,
|
||||||
beforeData: current ? { functionId: current.functionId, functionCode: current.functionCode, functionName: current.functionName, validFrom: current.validFrom } : { functionId: null },
|
beforeData: current ? { functionId: current.functionId, functionCode: current.functionCode, functionName: current.functionName, validFrom: current.validFrom } : { functionId: null },
|
||||||
afterData: { functionId: nextFunction.id, functionCode: nextFunction.code, functionName: nextFunction.name, effectiveAt, reason: dto.reason ?? null, versionNumber },
|
afterData: nextFunction
|
||||||
|
? { functionId: nextFunction.id, functionCode: nextFunction.code, functionName: nextFunction.name, effectiveAt, reason: dto.reason ?? null, versionNumber }
|
||||||
|
: { functionId: null, effectiveAt, reason: dto.reason ?? null, versionNumber },
|
||||||
metadata: { inventoryCode: asset.code, inventoryName: asset.name, temporal: true, historySource: 'inventory_function_assignments', assetVersionChangeType: AssetVersionChangeType.FUNCTION_CHANGED },
|
metadata: { inventoryCode: asset.code, inventoryName: asset.name, temporal: true, historySource: 'inventory_function_assignments', assetVersionChangeType: AssetVersionChangeType.FUNCTION_CHANGED },
|
||||||
}, manager);
|
}, manager);
|
||||||
return after;
|
return after;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async assignInitial(
|
||||||
|
manager: EntityManager,
|
||||||
|
assetId: string,
|
||||||
|
selection: { functionId?: string | null; newFunctionName?: string | null },
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<FunctionAssignmentRow | null> {
|
||||||
|
const functionId = selection.functionId?.trim() || null;
|
||||||
|
const newFunctionName = selection.newFunctionName?.trim() || null;
|
||||||
|
if (functionId && newFunctionName) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_FUNCTION_SELECTION_AMBIGUOUS',
|
||||||
|
message: 'Seleccioná una función del catálogo o cargá una nueva, no ambas opciones',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!functionId && !newFunctionName) return null;
|
||||||
|
|
||||||
|
const asset = await this.requireEligibleAsset(manager, assetId, true);
|
||||||
|
let nextFunction: InventoryFunctionRow;
|
||||||
|
if (functionId) {
|
||||||
|
nextFunction = await this.requireFunction(manager, functionId, true);
|
||||||
|
} else {
|
||||||
|
if (!newFunctionName || newFunctionName.length < 2) {
|
||||||
|
throw new BadRequestException({ code: 'INVENTORY_FUNCTION_NAME_REQUIRED', message: 'Ingresá el nombre de la nueva función' });
|
||||||
|
}
|
||||||
|
const [existing] = await manager.query(`
|
||||||
|
SELECT id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder",
|
||||||
|
created_at AS "createdAt",updated_at AS "updatedAt"
|
||||||
|
FROM inventory_functions WHERE lower(trim(name))=lower(trim($1)) LIMIT 1
|
||||||
|
`, [newFunctionName]) as InventoryFunctionRow[];
|
||||||
|
if (existing && !existing.isActive) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INVENTORY_FUNCTION_INACTIVE_EXISTS',
|
||||||
|
message: 'La función ya existe pero está inactiva. Reactivala desde el Catálogo de funciones.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (existing) nextFunction = existing;
|
||||||
|
else {
|
||||||
|
const slug = newFunctionName.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase()
|
||||||
|
.replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 72) || 'OTRA';
|
||||||
|
const digest = createHash('sha256').update(newFunctionName.toLocaleLowerCase('es')).digest('hex').slice(0, 10).toUpperCase();
|
||||||
|
const code = `FIELD_${slug}_${digest}`;
|
||||||
|
const [created] = await manager.query(`
|
||||||
|
INSERT INTO inventory_functions(code,name,description,is_active,sort_order,created_by,updated_by)
|
||||||
|
VALUES($1,$2,NULL,true,10000,$3,$3)
|
||||||
|
ON CONFLICT (code) DO UPDATE SET updated_at=CURRENT_TIMESTAMP
|
||||||
|
RETURNING id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder",
|
||||||
|
created_at AS "createdAt",updated_at AS "updatedAt"
|
||||||
|
`, [code, newFunctionName, principal.userId]) as InventoryFunctionRow[];
|
||||||
|
nextFunction = created;
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.INVENTORY_FUNCTION_CREATED,
|
||||||
|
entityType: 'inventory_function', entityId: created.id,
|
||||||
|
afterData: { ...created, source: 'FIELD' } as unknown as Record<string, unknown>,
|
||||||
|
}, manager);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = await this.currentAssignment(manager, assetId, true);
|
||||||
|
if (current?.functionId === nextFunction.id) return current;
|
||||||
|
const effectiveAt = new Date();
|
||||||
|
if (current) {
|
||||||
|
await manager.query(`UPDATE inventory_function_assignments SET valid_until=$2 WHERE id=$1 AND valid_until IS NULL`, [current.id, effectiveAt]);
|
||||||
|
}
|
||||||
|
await manager.query(`
|
||||||
|
INSERT INTO inventory_function_assignments(asset_id,function_id,valid_from,change_reason,changed_by)
|
||||||
|
VALUES($1,$2,$3,$4,$5)
|
||||||
|
`, [assetId, nextFunction.id, effectiveAt, newFunctionName ? 'Función incorporada desde alta de campo' : 'Función inicial', principal.userId]);
|
||||||
|
await manager.query(`UPDATE assets SET updated_by=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [assetId, principal.userId]);
|
||||||
|
const versionNumber = await this.history.capture(manager, assetId, AssetVersionChangeType.FUNCTION_CHANGED, principal, request);
|
||||||
|
const assigned = await this.currentAssignment(manager, assetId, false);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request), action: AuditAction.ASSET_FUNCTION_CHANGED,
|
||||||
|
entityType: 'asset', entityId: assetId,
|
||||||
|
beforeData: current ? { functionId: current.functionId, functionCode: current.functionCode, functionName: current.functionName, validFrom: current.validFrom } : { functionId: null },
|
||||||
|
afterData: { functionId: nextFunction.id, functionCode: nextFunction.code, functionName: nextFunction.name, effectiveAt, versionNumber },
|
||||||
|
metadata: { inventoryCode: asset.code, inventoryName: asset.name, temporal: true, source: newFunctionName ? 'FIELD' : 'CATALOG' },
|
||||||
|
}, manager);
|
||||||
|
return assigned;
|
||||||
|
}
|
||||||
|
|
||||||
private async getForAssetWithManager(manager: EntityManager, assetId: string) {
|
private async getForAssetWithManager(manager: EntityManager, assetId: string) {
|
||||||
const asset = await this.requireEligibleAsset(manager, assetId, false);
|
const asset = await this.requireEligibleAsset(manager, assetId, false);
|
||||||
const currentFunction = await this.currentAssignment(manager, assetId, false);
|
const currentFunction = await this.currentAssignment(manager, assetId, false);
|
||||||
@@ -178,9 +265,9 @@ export class InventoryFunctionService {
|
|||||||
WHERE asset.id=$1 ${lock ? 'FOR UPDATE OF asset' : ''}
|
WHERE asset.id=$1 ${lock ? 'FOR UPDATE OF asset' : ''}
|
||||||
`, [assetId]) as FunctionEligibleAsset[];
|
`, [assetId]) as FunctionEligibleAsset[];
|
||||||
if (!asset) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Inventario no encontrado' });
|
if (!asset) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Inventario no encontrado' });
|
||||||
const values = [asset.typeCode, asset.typeName, asset.familyCode, asset.familyName].map(normalized);
|
const values = [asset.typeCode, asset.typeName].map(normalized);
|
||||||
const eligible = values.some((value) => value === 'estacion' || value === 'subestacion' || value.includes('estacion ') || value.includes('subestacion ') || value.endsWith(' estacion') || value.endsWith(' subestacion'));
|
const eligible = values.some((value) => value === 'instalacion' || value === 'subinstalacion');
|
||||||
if (!eligible) throw new ConflictException({ code: 'INVENTORY_FUNCTION_CHANGE_NOT_ALLOWED', message: 'El cambio de función sólo está habilitado para Inventarios de Estación o Subestación' });
|
if (!eligible) throw new ConflictException({ code: 'INVENTORY_FUNCTION_CHANGE_NOT_ALLOWED', message: 'La función sólo se administra en Instalaciones y Subinstalaciones' });
|
||||||
return asset;
|
return asset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class F618FunctionCatalog1790146200000 implements MigrationInterface {
|
||||||
|
name = 'F618FunctionCatalog1790146200000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE asset_attribute_definitions definition
|
||||||
|
SET is_required=false, updated_at=CURRENT_TIMESTAMP
|
||||||
|
FROM asset_types type
|
||||||
|
WHERE type.id=definition.asset_type_id
|
||||||
|
AND lower(type.code) IN ('instalacion','subinstalacion')
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
WITH legacy AS (
|
||||||
|
SELECT DISTINCT trim(value.value #>> '{}') AS name
|
||||||
|
FROM asset_attribute_values value
|
||||||
|
JOIN asset_attribute_definitions definition ON definition.id=value.definition_id
|
||||||
|
JOIN asset_types type ON type.id=definition.asset_type_id
|
||||||
|
WHERE definition.code='campo_funcion'
|
||||||
|
AND lower(type.code) IN ('instalacion','subinstalacion')
|
||||||
|
AND nullif(trim(value.value #>> '{}'),'') IS NOT NULL
|
||||||
|
)
|
||||||
|
INSERT INTO inventory_functions(code,name,description,is_active,sort_order)
|
||||||
|
SELECT 'MIGRATED_' || upper(substr(md5(lower(name)),1,12)), name,
|
||||||
|
'Migrada desde el campo libre Función al catálogo F6.18.', true, 9000
|
||||||
|
FROM legacy
|
||||||
|
ON CONFLICT (code) DO UPDATE SET name=EXCLUDED.name,is_active=true,updated_at=CURRENT_TIMESTAMP
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO inventory_function_assignments(asset_id,function_id,valid_from,change_reason)
|
||||||
|
SELECT value.asset_id,function.id,COALESCE(value.updated_at,CURRENT_TIMESTAMP),'Migrado desde campo_funcion F6.18'
|
||||||
|
FROM asset_attribute_values value
|
||||||
|
JOIN asset_attribute_definitions definition ON definition.id=value.definition_id
|
||||||
|
JOIN asset_types type ON type.id=definition.asset_type_id
|
||||||
|
JOIN inventory_functions function
|
||||||
|
ON function.code='MIGRATED_' || upper(substr(md5(lower(trim(value.value #>> '{}'))),1,12))
|
||||||
|
WHERE definition.code='campo_funcion'
|
||||||
|
AND lower(type.code) IN ('instalacion','subinstalacion')
|
||||||
|
AND nullif(trim(value.value #>> '{}'),'') IS NOT NULL
|
||||||
|
ON CONFLICT (asset_id) WHERE valid_until IS NULL DO NOTHING
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE asset_attribute_definitions definition
|
||||||
|
SET is_active=false,is_required=false,updated_at=CURRENT_TIMESTAMP
|
||||||
|
FROM asset_types type
|
||||||
|
WHERE type.id=definition.asset_type_id
|
||||||
|
AND lower(type.code) IN ('instalacion','subinstalacion')
|
||||||
|
AND definition.code='campo_funcion'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE asset_attribute_definitions definition
|
||||||
|
SET is_active=true,is_required=false,updated_at=CURRENT_TIMESTAMP
|
||||||
|
FROM asset_types type
|
||||||
|
WHERE type.id=definition.asset_type_id
|
||||||
|
AND lower(type.code) IN ('instalacion','subinstalacion')
|
||||||
|
AND definition.code='campo_funcion'
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO asset_attribute_values(asset_id,definition_id,value,updated_at)
|
||||||
|
SELECT assignment.asset_id,definition.id,to_jsonb(function.name),CURRENT_TIMESTAMP
|
||||||
|
FROM inventory_function_assignments assignment
|
||||||
|
JOIN inventory_functions function ON function.id=assignment.function_id
|
||||||
|
JOIN assets asset ON asset.id=assignment.asset_id
|
||||||
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
JOIN asset_attribute_definitions definition
|
||||||
|
ON definition.asset_type_id=asset.asset_type_id AND definition.code='campo_funcion'
|
||||||
|
WHERE assignment.change_reason='Migrado desde campo_funcion F6.18'
|
||||||
|
AND assignment.valid_until IS NULL
|
||||||
|
ON CONFLICT (asset_id,definition_id) DO UPDATE SET value=EXCLUDED.value,updated_at=CURRENT_TIMESTAMP
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`DELETE FROM inventory_function_assignments WHERE change_reason='Migrado desde campo_funcion F6.18'`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM inventory_functions function
|
||||||
|
WHERE function.code LIKE 'MIGRATED_%'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM inventory_function_assignments assignment WHERE assignment.function_id=function.id)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
type UploadedInspectionSignatureFile,
|
type UploadedInspectionSignatureFile,
|
||||||
} from './inspection-signature-file';
|
} from './inspection-signature-file';
|
||||||
|
|
||||||
const CLOSURE_SCHEMA_VERSION = 'DH-ACT-LIFECYCLE-V5';
|
const CLOSURE_SCHEMA_VERSION = 'DH-ACT-LIFECYCLE-V6';
|
||||||
const CONSENT_VERSION = 'F4-1';
|
const CONSENT_VERSION = 'F4-1';
|
||||||
const INSPECTOR_CONSENT = 'Declaro que revisé el contenido del acta bloqueada y que esta firma deja constancia de mi intervención como inspector/a.';
|
const INSPECTOR_CONSENT = 'Declaro que revisé el contenido del acta bloqueada y que esta firma deja constancia de mi intervención como inspector/a.';
|
||||||
const COMPANY_CONSENT = 'Declaro haber accedido al contenido íntegro del acta bloqueada y que esta firma electrónica deja constancia de mi recepción y manifestación, sin alterar el contenido del acta.';
|
const COMPANY_CONSENT = 'Declaro haber accedido al contenido íntegro del acta bloqueada y que esta firma electrónica deja constancia de mi recepción y manifestación, sin alterar el contenido del acta.';
|
||||||
@@ -973,6 +973,23 @@ export class InspectionClosingService {
|
|||||||
SELECT selected.id,selected.code,selected.name,selected.common_name AS "commonName",selected.current_version AS "currentVersion",
|
SELECT selected.id,selected.code,selected.name,selected.common_name AS "commonName",selected.current_version AS "currentVersion",
|
||||||
selected.type_code AS "typeCode",selected.type_name AS "typeName",
|
selected.type_code AS "typeCode",selected.type_name AS "typeName",
|
||||||
selected.family_code AS "installationTypeCode",selected.family_name AS "installationTypeName",
|
selected.family_code AS "installationTypeCode",selected.family_name AS "installationTypeName",
|
||||||
|
(SELECT JSONB_BUILD_OBJECT('id',fn.id,'code',fn.code,'name',fn.name)
|
||||||
|
FROM inventory_function_assignments assignment
|
||||||
|
JOIN inventory_functions fn ON fn.id=assignment.function_id
|
||||||
|
WHERE assignment.asset_id=selected.id AND assignment.valid_until IS NULL
|
||||||
|
ORDER BY assignment.valid_from DESC,assignment.created_at DESC LIMIT 1) AS "function",
|
||||||
|
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||||
|
'code',definition.code,'name',definition.name,'unit',definition.unit,
|
||||||
|
'dataType',definition.data_type,'value',value.value,'sortOrder',definition.sort_order
|
||||||
|
) ORDER BY definition.sort_order,definition.name,definition.code)
|
||||||
|
FROM asset_attribute_values value
|
||||||
|
JOIN asset_attribute_definitions definition ON definition.id=value.definition_id
|
||||||
|
WHERE value.asset_id=selected.id
|
||||||
|
AND definition.is_active=true
|
||||||
|
AND definition.code<>'campo_funcion'
|
||||||
|
AND value.value IS NOT NULL
|
||||||
|
AND (jsonb_typeof(value.value)<>'string' OR NULLIF(trim(value.value #>> '{}'),'') IS NOT NULL)
|
||||||
|
),'[]'::jsonb) AS attributes,
|
||||||
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',lineage.id,'code',lineage.code,'name',lineage.name,'typeCode',lineage.type_code,'typeName',lineage.type_name) ORDER BY lineage.depth DESC) FROM lineage WHERE lineage.root_id=selected.id),'[]'::jsonb) AS path
|
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',lineage.id,'code',lineage.code,'name',lineage.name,'typeCode',lineage.type_code,'typeName',lineage.type_name) ORDER BY lineage.depth DESC) FROM lineage WHERE lineage.root_id=selected.id),'[]'::jsonb) AS path
|
||||||
FROM selected ORDER BY selected.code,selected.id
|
FROM selected ORDER BY selected.code,selected.id
|
||||||
`, [actId]) as Array<Record<string, unknown>>;
|
`, [actId]) as Array<Record<string, unknown>>;
|
||||||
|
|||||||
@@ -36,6 +36,17 @@ function asArray(value: unknown): Array<Record<string, unknown>> {
|
|||||||
function text(value: unknown, fallback = ''): string {
|
function text(value: unknown, fallback = ''): string {
|
||||||
return String(value ?? '').trim() || fallback;
|
return String(value ?? '').trim() || fallback;
|
||||||
}
|
}
|
||||||
|
function sameText(left: unknown, right: unknown): boolean {
|
||||||
|
const normalize = (value: unknown) => text(value).normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim().toLowerCase();
|
||||||
|
return normalize(left) !== '' && normalize(left) === normalize(right);
|
||||||
|
}
|
||||||
|
function attributeText(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'boolean') return value ? 'Sí' : 'No';
|
||||||
|
if (typeof value === 'string' || typeof value === 'number') return text(value);
|
||||||
|
if (Array.isArray(value)) return value.map(attributeText).filter(Boolean).join(', ');
|
||||||
|
try { return JSON.stringify(value); } catch { return text(value); }
|
||||||
|
}
|
||||||
function date(value: unknown): string {
|
function date(value: unknown): string {
|
||||||
const parsed = new Date(String(value ?? ''));
|
const parsed = new Date(String(value ?? ''));
|
||||||
return Number.isFinite(parsed.getTime())
|
return Number.isFinite(parsed.getTime())
|
||||||
@@ -228,8 +239,24 @@ export async function buildInspectionActPdf(
|
|||||||
need(100);
|
need(100);
|
||||||
doc.font('body-bold').fillColor(blue).fontSize(12).text(`HALLAZGO N° ${number} · ${text(finding.code)}`);
|
doc.font('body-bold').fillColor(blue).fontSize(12).text(`HALLAZGO N° ${number} · ${text(finding.code)}`);
|
||||||
doc.moveDown(0.2);
|
doc.moveDown(0.2);
|
||||||
label('Elemento afectado', `${text(inventory.typeName)} - ${text(inventory.name)}${text(inventory.code) ? ` [${text(inventory.code)}]` : ''}`);
|
const habitualName = text(inventory.commonName) || text(inventory.name);
|
||||||
|
const technicalName = text(inventory.name);
|
||||||
|
const inventoryFunction = asRecord(inventory.function);
|
||||||
|
const technicalAttributes = asArray(inventory.attributes);
|
||||||
|
label('Elemento afectado', `${text(inventory.typeName)} - ${habitualName}${text(inventory.code) ? ` [${text(inventory.code)}]` : ''}`);
|
||||||
|
label('Nombre habitual', habitualName);
|
||||||
|
if (technicalName && !sameText(technicalName, habitualName)) label('Nombre técnico', technicalName);
|
||||||
|
if (text(inventory.code)) label('Código DH', inventory.code);
|
||||||
|
if (text(inventory.typeName)) label('Tipo de elemento', inventory.typeName);
|
||||||
if (text(inventory.installationTypeName)) label('Tipo de instalación', inventory.installationTypeName);
|
if (text(inventory.installationTypeName)) label('Tipo de instalación', inventory.installationTypeName);
|
||||||
|
if (text(inventoryFunction.name)) label('Función', inventoryFunction.name);
|
||||||
|
for (const attribute of technicalAttributes) {
|
||||||
|
if (text(attribute.code) === 'campo_funcion') continue;
|
||||||
|
const value = attributeText(attribute.value);
|
||||||
|
if (!value) continue;
|
||||||
|
const unit = text(attribute.unit);
|
||||||
|
label(text(attribute.name, text(attribute.code)), unit ? `${value} ${unit}` : value);
|
||||||
|
}
|
||||||
if (route) label('Ubicación / Ruta jerárquica', route);
|
if (route) label('Ubicación / Ruta jerárquica', route);
|
||||||
label('Denominación del hallazgo', finding.title);
|
label('Denominación del hallazgo', finding.title);
|
||||||
subheading('Qué se constató');
|
subheading('Qué se constató');
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ export class CreateFieldInventoryDto {
|
|||||||
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
|
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
|
||||||
code?: string;
|
code?: string;
|
||||||
|
|
||||||
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
name!: string;
|
name?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
@@ -48,6 +48,16 @@ export class CreateFieldInventoryDto {
|
|||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
commonName?: string | null;
|
commonName?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
functionId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(240)
|
||||||
|
newFunctionName?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -306,8 +306,10 @@ export class FieldInventoryService {
|
|||||||
clientGeneratedId: dto.clientGeneratedId,
|
clientGeneratedId: dto.clientGeneratedId,
|
||||||
visitId,
|
visitId,
|
||||||
code,
|
code,
|
||||||
name: dto.name,
|
name: dto.name ?? null,
|
||||||
commonName: dto.commonName ?? null,
|
commonName: dto.commonName ?? null,
|
||||||
|
functionId: dto.functionId ?? null,
|
||||||
|
newFunctionName: dto.newFunctionName ?? null,
|
||||||
typeId: dto.typeId,
|
typeId: dto.typeId,
|
||||||
parentId,
|
parentId,
|
||||||
operationalAreaId: context.areaId,
|
operationalAreaId: context.areaId,
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export const API_VERSION = '0.29.0-17';
|
export const API_VERSION = '0.29.0-18';
|
||||||
export const API_PHASE = 'F6.17';
|
export const API_PHASE = 'F6.18';
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ test('Acta PDF explains each Hallazgo with frozen territorial, technical and rec
|
|||||||
assert.match(pdf, /Alcance de la inspección/);
|
assert.match(pdf, /Alcance de la inspección/);
|
||||||
assert.match(pdf, /scopeTypeCode/);
|
assert.match(pdf, /scopeTypeCode/);
|
||||||
assert.match(pdf, /Antecedente relacionado/);
|
assert.match(pdf, /Antecedente relacionado/);
|
||||||
assert.match(closing, /DH-ACT-LIFECYCLE-V5/);
|
assert.match(closing, /DH-ACT-LIFECYCLE-V6/);
|
||||||
assert.match(closing, /AS department/);
|
assert.match(closing, /AS department/);
|
||||||
assert.match(closing, /AS "leadInspector"/);
|
assert.match(closing, /AS "leadInspector"/);
|
||||||
assert.match(closing, /AS "installationTypeName"/);
|
assert.match(closing, /AS "installationTypeName"/);
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { API_PHASE, API_VERSION } from '../../src/version';
|
import { API_PHASE, API_VERSION } from '../../src/version';
|
||||||
|
|
||||||
test('health metadata reports the current F6.17 release', () => {
|
test('health metadata reports the current F6.18 release', () => {
|
||||||
assert.equal(API_PHASE, 'F6.17');
|
assert.equal(API_PHASE, 'F6.18');
|
||||||
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
|
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
|
||||||
assert.equal(API_VERSION, pkg.version);
|
assert.equal(API_VERSION, pkg.version);
|
||||||
assert.equal(API_VERSION, '0.29.0-17');
|
assert.equal(API_VERSION, '0.29.0-18');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string {
|
|||||||
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
|
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
|
||||||
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
|
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
|
||||||
|
|
||||||
assert.match(gradle, /versionCode = 41/);
|
assert.match(gradle, /versionCode = 42/);
|
||||||
assert.match(gradle, /versionName = "0\.19\.13"/);
|
assert.match(gradle, /versionName = "0\.19\.14"/);
|
||||||
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
||||||
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
|
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { MODULE_METADATA } from '@nestjs/common/constants';
|
|||||||
import { AssetMasterModule } from '../../src/asset-master/asset-master.module';
|
import { AssetMasterModule } from '../../src/asset-master/asset-master.module';
|
||||||
import { InventoryFunctionService } from '../../src/asset-master/inventory-function.service';
|
import { InventoryFunctionService } from '../../src/asset-master/inventory-function.service';
|
||||||
|
|
||||||
test('F5 AssetMasterModule keeps dossier-only function dependency injectable without reopening its controller', () => {
|
test('F5 dossier dependency remains injectable and F6.18 reopens the Function catalog controller', () => {
|
||||||
const providers = (Reflect.getMetadata(MODULE_METADATA.PROVIDERS, AssetMasterModule) ?? []) as unknown[];
|
const providers = (Reflect.getMetadata(MODULE_METADATA.PROVIDERS, AssetMasterModule) ?? []) as unknown[];
|
||||||
const controllers = (Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, AssetMasterModule) ?? []) as Array<{ name?: string }>;
|
const controllers = (Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, AssetMasterModule) ?? []) as Array<{ name?: string }>;
|
||||||
const dossierSource = readFileSync(
|
const dossierSource = readFileSync(
|
||||||
@@ -25,7 +25,7 @@ test('F5 AssetMasterModule keeps dossier-only function dependency injectable wit
|
|||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
controllers.some((controller) => controller?.name === 'InventoryFunctionController'),
|
controllers.some((controller) => controller?.name === 'InventoryFunctionController'),
|
||||||
false,
|
true,
|
||||||
'F5 must not reopen the retired Inventory Function controller',
|
'F6.18 explicitly reopens InventoryFunctionController as the authoritative Function catalog',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack
|
|||||||
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
|
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
|
||||||
|
|
||||||
assert.equal(visibleVersion, pkg.version);
|
assert.equal(visibleVersion, pkg.version);
|
||||||
assert.match(version, /APP_PHASE\s*=\s*'F6\.17/);
|
assert.match(version, /APP_PHASE\s*=\s*'F6\.18/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
|
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||||
|
const root = (path: string) => readFileSync(resolve(process.cwd(), '..', path), 'utf8');
|
||||||
|
|
||||||
|
test('F6.18 reuses one auditable Function catalog for Installation and Subinstallation', () => {
|
||||||
|
const module = api('src/asset-master/asset-master.module.ts');
|
||||||
|
const service = api('src/asset-master/inventory-function.service.ts');
|
||||||
|
const migration = api('src/database/migrations/1790146200000-f6-18-function-catalog.ts');
|
||||||
|
const app = root('web-v2/src/app/App.tsx');
|
||||||
|
const nav = root('web-v2/src/layout/AppLayout.tsx');
|
||||||
|
assert.match(module, /InventoryFunctionController/);
|
||||||
|
assert.match(service, /value === 'instalacion' \|\| value === 'subinstalacion'/);
|
||||||
|
assert.match(service, /newFunctionName/);
|
||||||
|
assert.match(service, /FIELD_\$\{slug\}_\$\{digest\}/);
|
||||||
|
assert.match(migration, /definition\.code='campo_funcion'/);
|
||||||
|
assert.match(migration, /inventory_function_assignments/);
|
||||||
|
assert.match(migration, /is_active=false,is_required=false/);
|
||||||
|
assert.match(app, /admin\/inventory-functions/);
|
||||||
|
assert.match(nav, /Catálogo de funciones/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.18 makes habitual name mandatory and technical name optional for technical Inventory', () => {
|
||||||
|
const createDto = api('src/asset-master/dto/create-asset.dto.ts');
|
||||||
|
const fieldDto = api('src/inspection-visits/dto/create-field-inventory.dto.ts');
|
||||||
|
const service = api('src/asset-master/assets.service.ts');
|
||||||
|
const editor = root('web-v2/src/pages/AssetEditorPage.tsx');
|
||||||
|
const mobile = root('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt');
|
||||||
|
assert.match(createDto, /name\?: string \| null/);
|
||||||
|
assert.match(fieldDto, /name\?: string \| null/);
|
||||||
|
assert.match(service, /ASSET_COMMON_NAME_REQUIRED/);
|
||||||
|
assert.match(service, /technicalName \|\| habitualName/);
|
||||||
|
assert.match(editor, /Nombre habitual/);
|
||||||
|
assert.match(editor, /Nombre técnico <em>opcional<\/em>/);
|
||||||
|
assert.match(mobile, /Nombre habitual \*/);
|
||||||
|
assert.match(mobile, /Nombre técnico \(opcional\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.18 APK can select a catalog Function or add Otro and keeps it in the offline queue', () => {
|
||||||
|
const data = root('android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt');
|
||||||
|
const mobile = root('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt');
|
||||||
|
const queue = root('android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/offline/OfflineQueue.kt');
|
||||||
|
assert.match(data, /@GET\("inventory-functions"\)/);
|
||||||
|
assert.match(data, /val functionId: String\? = null/);
|
||||||
|
assert.match(data, /val newFunctionName: String\? = null/);
|
||||||
|
assert.match(mobile, /Otro · agregar función/);
|
||||||
|
assert.match(mobile, /Nueva función \*/);
|
||||||
|
assert.match(queue, /put\("functionId", request\.functionId\)/);
|
||||||
|
assert.match(queue, /put\("newFunctionName", request\.newFunctionName\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.18 freezes every completed technical datum except Inventory description into the Acta', () => {
|
||||||
|
const closing = api('src/inspection-closing/inspection-closing.service.ts');
|
||||||
|
const pdf = api('src/inspection-reports/inspection-act-pdf-builder.ts');
|
||||||
|
assert.match(closing, /inventory_function_assignments/);
|
||||||
|
assert.match(closing, /AS "function"/);
|
||||||
|
assert.match(closing, /AS attributes/);
|
||||||
|
assert.match(closing, /definition\.code<>'campo_funcion'/);
|
||||||
|
assert.doesNotMatch(closing, /selected\.description/);
|
||||||
|
assert.match(pdf, /label\('Nombre habitual'/);
|
||||||
|
assert.match(pdf, /label\('Nombre técnico'/);
|
||||||
|
assert.match(pdf, /label\('Código DH'/);
|
||||||
|
assert.match(pdf, /label\('Función'/);
|
||||||
|
assert.match(pdf, /technicalAttributes/);
|
||||||
|
assert.doesNotMatch(pdf, /inventory\.description/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# F6.18 · Catálogo de funciones e identidad documental
|
||||||
|
|
||||||
|
Alcance exclusivo del Inventario técnico: Nombre habitual obligatorio, Nombre técnico opcional, campos técnicos opcionales y Función catalogada para Instalaciones/Subinstalaciones.
|
||||||
|
|
||||||
|
La migración conserva valores históricos de `campo_funcion`, los convierte en catálogo + asignación temporal y retira el texto libre como fuente de verdad. La APK reutiliza el catálogo y puede incorporar `Otro` de forma auditada. El Acta congela y muestra todos los datos completados del elemento con Hallazgo, excepto la Descripción del Inventario.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-web",
|
"name": "dhv2-web",
|
||||||
"version": "0.23.0-13",
|
"version": "0.23.0-14",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dhv2-web",
|
"name": "dhv2-web",
|
||||||
"version": "0.23.0-13",
|
"version": "0.23.0-14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"maplibre-gl": "6.4.1",
|
"maplibre-gl": "6.4.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-web",
|
"name": "dhv2-web",
|
||||||
"version": "0.23.0-13",
|
"version": "0.23.0-14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { VerificationPlanningPage } from '../pages/VerificationPlanningPage';
|
|||||||
import { ActAdministrationPage } from '../pages/ActAdministrationPage';
|
import { ActAdministrationPage } from '../pages/ActAdministrationPage';
|
||||||
import { ActAdministrationDetailPage } from '../pages/ActAdministrationDetailPage';
|
import { ActAdministrationDetailPage } from '../pages/ActAdministrationDetailPage';
|
||||||
import { FieldBriefingsPage } from '../pages/FieldBriefingsPage';
|
import { FieldBriefingsPage } from '../pages/FieldBriefingsPage';
|
||||||
|
import { InventoryFunctionsPage } from '../pages/InventoryFunctionsPage';
|
||||||
|
|
||||||
const MapPage = lazy(() => import('../pages/MapPage').then((module) => ({ default: module.MapPage })));
|
const MapPage = lazy(() => import('../pages/MapPage').then((module) => ({ default: module.MapPage })));
|
||||||
const DocumentDeliveryPage = lazy(() => import('../pages/DocumentDeliveryPage').then((module) => ({ default: module.DocumentDeliveryPage })));
|
const DocumentDeliveryPage = lazy(() => import('../pages/DocumentDeliveryPage').then((module) => ({ default: module.DocumentDeliveryPage })));
|
||||||
@@ -82,6 +83,7 @@ export function App() {
|
|||||||
<Route element={<PermissionRoute permission="roles.read" />}><Route path="/admin/roles" element={<RolesPage />} /></Route>
|
<Route element={<PermissionRoute permission="roles.read" />}><Route path="/admin/roles" element={<RolesPage />} /></Route>
|
||||||
<Route element={<PermissionRoute permission="audit.read" />}><Route path="/admin/audit" element={<AuditPage />} /></Route>
|
<Route element={<PermissionRoute permission="audit.read" />}><Route path="/admin/audit" element={<AuditPage />} /></Route>
|
||||||
<Route element={<PermissionRoute permission="asset_types.read" />}><Route path="/admin/asset-types" element={<AuthoritativeInventoryConfigPage />} /></Route>
|
<Route element={<PermissionRoute permission="asset_types.read" />}><Route path="/admin/asset-types" element={<AuthoritativeInventoryConfigPage />} /></Route>
|
||||||
|
<Route element={<PermissionRoute permission="asset_types.manage" />}><Route path="/admin/inventory-functions" element={<InventoryFunctionsPage />} /></Route>
|
||||||
<Route element={<PermissionRoute permission="finding_catalog.manage" />}><Route path="/admin/finding-catalog" element={<FindingCatalogPage />} /></Route>
|
<Route element={<PermissionRoute permission="finding_catalog.manage" />}><Route path="/admin/finding-catalog" element={<FindingCatalogPage />} /></Route>
|
||||||
<Route element={<PermissionRoute permission="document_delivery.read" />}><Route path="/admin/document-delivery" element={<DocumentDeliveryPage />} /></Route>
|
<Route element={<PermissionRoute permission="document_delivery.read" />}><Route path="/admin/document-delivery" element={<DocumentDeliveryPage />} /></Route>
|
||||||
<Route path="/sin-acceso" element={<AccessDeniedPage />} />
|
<Route path="/sin-acceso" element={<AccessDeniedPage />} />
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '0.23.0-13';
|
export const APP_VERSION = '0.23.0-14';
|
||||||
export const APP_PHASE = 'F6.17 · Tablas ordenables';
|
export const APP_PHASE = 'F6.18 · Catálogo de funciones';
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function normalized(value: string): string {
|
|||||||
|
|
||||||
function supportsFunctionChange(code: string, name: string): boolean {
|
function supportsFunctionChange(code: string, name: string): boolean {
|
||||||
const value = `${normalized(code)} ${normalized(name)}`;
|
const value = `${normalized(code)} ${normalized(name)}`;
|
||||||
return value.split(' ').some((part) => part === 'estacion' || part === 'subestacion');
|
return value.split(' ').some((part) => part === 'instalacion' || part === 'subinstalacion');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssetHistoryPanel({
|
export function AssetHistoryPanel({
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function InventoryFunctionPanel({ assetId }: { assetId: string }) {
|
|||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
<span className="eyebrow">FUNCIÓN OPERATIVA</span>
|
<span className="eyebrow">FUNCIÓN OPERATIVA</span>
|
||||||
<h2>Función de la Estación / Subestación</h2>
|
<h2>Función de la Instalación / Subinstalación</h2>
|
||||||
<p className="section-copy">La identidad del Inventario no cambia. Cada mutación de función conserva fecha, usuario y antecedente para la consulta histórica.</p>
|
<p className="section-copy">La identidad del Inventario no cambia. Cada mutación de función conserva fecha, usuario y antecedente para la consulta histórica.</p>
|
||||||
</div>
|
</div>
|
||||||
{canManageCatalog && <Link className="button secondary" to="/admin/inventory-functions">Administrar catálogo</Link>}
|
{canManageCatalog && <Link className="button secondary" to="/admin/inventory-functions">Administrar catálogo</Link>}
|
||||||
@@ -110,7 +110,7 @@ export function InventoryFunctionPanel({ assetId }: { assetId: string }) {
|
|||||||
{canChange && <form className="form-section" onSubmit={submit}>
|
{canChange && <form className="form-section" onSubmit={submit}>
|
||||||
<div>
|
<div>
|
||||||
<h3>Cambiar función</h3>
|
<h3>Cambiar función</h3>
|
||||||
<p className="section-copy">Ejemplo: Bombeo Mecánico AIB → Bombeo Mecánico Rotaflex. No se crea una Estación nueva.</p>
|
<p className="section-copy">La Función cambia sin crear un nuevo registro de Inventario y conserva su historial.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const administration: NavItem[] = [
|
|||||||
{ to: '/admin/users', label: 'Usuarios', icon: 'users', permission: 'users.read' },
|
{ to: '/admin/users', label: 'Usuarios', icon: 'users', permission: 'users.read' },
|
||||||
{ to: '/admin/roles', label: 'Roles y permisos', icon: 'shield', permission: 'roles.read' },
|
{ to: '/admin/roles', label: 'Roles y permisos', icon: 'shield', permission: 'roles.read' },
|
||||||
{ to: '/admin/asset-types', label: 'Configuración de Inventarios', icon: 'layers', permission: 'asset_types.read' },
|
{ to: '/admin/asset-types', label: 'Configuración de Inventarios', icon: 'layers', permission: 'asset_types.read' },
|
||||||
|
{ to: '/admin/inventory-functions', label: 'Catálogo de funciones', icon: 'layers', permission: 'asset_types.manage' },
|
||||||
{ to: '/admin/finding-catalog', label: 'Catálogo de hallazgos', icon: 'alert', permission: 'finding_catalog.manage' },
|
{ to: '/admin/finding-catalog', label: 'Catálogo de hallazgos', icon: 'alert', permission: 'finding_catalog.manage' },
|
||||||
{ to: '/admin/document-delivery', label: 'Entrega documental', icon: 'audit', permission: 'document_delivery.read' },
|
{ to: '/admin/document-delivery', label: 'Entrega documental', icon: 'audit', permission: 'document_delivery.read' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2061,6 +2061,7 @@ export function createAsset(input: {
|
|||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
commonName?: string | null;
|
commonName?: string | null;
|
||||||
|
functionId?: string | null;
|
||||||
typeId: string;
|
typeId: string;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
operationalAreaId?: string | null;
|
operationalAreaId?: string | null;
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function getInventoryFunctionHistory(assetId: string) {
|
|||||||
|
|
||||||
export function changeInventoryFunction(
|
export function changeInventoryFunction(
|
||||||
assetId: string,
|
assetId: string,
|
||||||
input: { functionId: string; effectiveAt?: string; reason?: string | null },
|
input: { functionId: string | null; effectiveAt?: string; reason?: string | null },
|
||||||
) {
|
) {
|
||||||
return apiRequest<InventoryFunctionHistory>(`/assets/${assetId}/function`, {
|
return apiRequest<InventoryFunctionHistory>(`/assets/${assetId}/function`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import {
|
|||||||
updateAssetInformationStatus,
|
updateAssetInformationStatus,
|
||||||
updateAssetOperationalStatus,
|
updateAssetOperationalStatus,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
|
import { changeInventoryFunction, getInventoryFunctionHistory, listInventoryFunctions } from '../lib/inventoryFunctionApi';
|
||||||
|
import type { InventoryFunction } from '../lib/inventoryFunctionApi';
|
||||||
import type {
|
import type {
|
||||||
AssetAttributeDefinition,
|
AssetAttributeDefinition,
|
||||||
AssetDetail,
|
AssetDetail,
|
||||||
@@ -140,6 +142,9 @@ export function AssetEditorPage() {
|
|||||||
const [operationalAreas, setOperationalAreas] = useState<OperationalAssetSummary[]>([]);
|
const [operationalAreas, setOperationalAreas] = useState<OperationalAssetSummary[]>([]);
|
||||||
const [operationalCompanies, setOperationalCompanies] = useState<OperationalAssetSummary[]>([]);
|
const [operationalCompanies, setOperationalCompanies] = useState<OperationalAssetSummary[]>([]);
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
|
const [functionCatalog, setFunctionCatalog] = useState<InventoryFunction[]>([]);
|
||||||
|
const [functionId, setFunctionId] = useState('');
|
||||||
|
const [currentFunctionId, setCurrentFunctionId] = useState('');
|
||||||
const [status, setStatus] = useState<AssetInformationStatus>('DRAFT');
|
const [status, setStatus] = useState<AssetInformationStatus>('DRAFT');
|
||||||
const [operationalStatus, setOperationalStatus] = useState<AssetOperationalStatus>('UNKNOWN');
|
const [operationalStatus, setOperationalStatus] = useState<AssetOperationalStatus>('UNKNOWN');
|
||||||
const [attributeValues, setAttributeValues] = useState<Record<string, unknown>>({});
|
const [attributeValues, setAttributeValues] = useState<Record<string, unknown>>({});
|
||||||
@@ -154,9 +159,21 @@ export function AssetEditorPage() {
|
|||||||
|
|
||||||
const selectedType = types.find((type) => type.id === typeId) ?? null;
|
const selectedType = types.find((type) => type.id === typeId) ?? null;
|
||||||
const definitions = useMemo(
|
const definitions = useMemo(
|
||||||
() => selectedType?.attributes.filter((item) => item.isActive) ?? [],
|
() => selectedType?.attributes.filter((item) => item.isActive && item.code !== 'campo_funcion') ?? [],
|
||||||
[selectedType],
|
[selectedType],
|
||||||
);
|
);
|
||||||
|
const structuralTypeCode = normalizeStructuralType(selectedType?.code);
|
||||||
|
const structuralTypeName = normalizeStructuralType(selectedType?.name);
|
||||||
|
const technicalInventory = structuralTypeCode === 'instalacion'
|
||||||
|
|| structuralTypeCode === 'subinstalacion'
|
||||||
|
|| structuralTypeName === 'instalacion'
|
||||||
|
|| structuralTypeName === 'subinstalacion';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listInventoryFunctions()
|
||||||
|
.then(setFunctionCatalog)
|
||||||
|
.catch((requestError) => setError(errorMessage(requestError)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -215,6 +232,20 @@ export function AssetEditorPage() {
|
|||||||
.catch((requestError) => setError(errorMessage(requestError)));
|
.catch((requestError) => setError(errorMessage(requestError)));
|
||||||
}, [editing, contextParentId, types]);
|
}, [editing, contextParentId, types]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing || !id || !technicalInventory || !canReadHistory) {
|
||||||
|
if (!editing) { setFunctionId(''); setCurrentFunctionId(''); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
getInventoryFunctionHistory(id)
|
||||||
|
.then((history) => {
|
||||||
|
const value = history.currentFunction?.functionId ?? '';
|
||||||
|
setFunctionId(value);
|
||||||
|
setCurrentFunctionId(value);
|
||||||
|
})
|
||||||
|
.catch((requestError) => setError(errorMessage(requestError)));
|
||||||
|
}, [editing, id, technicalInventory, canReadHistory]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!typeId) {
|
if (!typeId) {
|
||||||
setParents([]);
|
setParents([]);
|
||||||
@@ -274,6 +305,8 @@ export function AssetEditorPage() {
|
|||||||
setOperationalAreaId('');
|
setOperationalAreaId('');
|
||||||
if (!editing) setOperatorCompanyId('');
|
if (!editing) setOperatorCompanyId('');
|
||||||
setAttributeValues({});
|
setAttributeValues({});
|
||||||
|
setFunctionId('');
|
||||||
|
setCurrentFunctionId('');
|
||||||
setParentSearch('');
|
setParentSearch('');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -287,14 +320,19 @@ export function AssetEditorPage() {
|
|||||||
setError('');
|
setError('');
|
||||||
setSuccess('');
|
setSuccess('');
|
||||||
try {
|
try {
|
||||||
|
if (technicalInventory && !commonName.trim()) {
|
||||||
|
setError('Completá el Nombre habitual. Es obligatorio para Instalaciones y Subinstalaciones.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const attributes = normalizeAttributeValues(definitions, attributeValues);
|
const attributes = normalizeAttributeValues(definitions, attributeValues);
|
||||||
|
const nameToSave = technicalInventory ? (name.trim() || commonName.trim()) : name.trim();
|
||||||
let saved: AssetDetail;
|
let saved: AssetDetail;
|
||||||
if (editing && id) {
|
if (editing && id) {
|
||||||
if (canEdit) {
|
if (canEdit) {
|
||||||
saved = await updateAsset(id, {
|
saved = await updateAsset(id, {
|
||||||
typeId: canDirectContextEdit ? typeId : undefined,
|
typeId: canDirectContextEdit ? typeId : undefined,
|
||||||
code,
|
code,
|
||||||
name,
|
name: nameToSave,
|
||||||
commonName: commonName.trim() || null,
|
commonName: commonName.trim() || null,
|
||||||
parentId: canDirectContextEdit ? parentId || null : undefined,
|
parentId: canDirectContextEdit ? parentId || null : undefined,
|
||||||
operationalAreaId: canDirectContextEdit ? operationalAreaId || null : undefined,
|
operationalAreaId: canDirectContextEdit ? operationalAreaId || null : undefined,
|
||||||
@@ -310,14 +348,21 @@ export function AssetEditorPage() {
|
|||||||
if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) {
|
if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) {
|
||||||
saved = await updateAssetOperationalStatus(id, operationalStatus);
|
saved = await updateAssetOperationalStatus(id, operationalStatus);
|
||||||
}
|
}
|
||||||
|
if (technicalInventory && functionId !== currentFunctionId) {
|
||||||
|
const functionHistory = await changeInventoryFunction(id, { functionId: functionId || null });
|
||||||
|
const nextFunctionId = functionHistory.currentFunction?.functionId ?? '';
|
||||||
|
setCurrentFunctionId(nextFunctionId);
|
||||||
|
setFunctionId(nextFunctionId);
|
||||||
|
}
|
||||||
setAsset(saved);
|
setAsset(saved);
|
||||||
setSuccess('Registro actualizado correctamente');
|
setSuccess('Registro actualizado correctamente');
|
||||||
setHistoryRefreshKey((current) => current + 1);
|
setHistoryRefreshKey((current) => current + 1);
|
||||||
} else {
|
} else {
|
||||||
saved = await createAsset({
|
saved = await createAsset({
|
||||||
code,
|
code,
|
||||||
name,
|
name: nameToSave,
|
||||||
commonName: commonName.trim() || null,
|
commonName: commonName.trim() || null,
|
||||||
|
functionId: technicalInventory ? (functionId || null) : null,
|
||||||
typeId,
|
typeId,
|
||||||
parentId: parentId || null,
|
parentId: parentId || null,
|
||||||
operationalAreaId: operationalAreaId || null,
|
operationalAreaId: operationalAreaId || null,
|
||||||
@@ -351,8 +396,6 @@ export function AssetEditorPage() {
|
|||||||
? 'companies'
|
? 'companies'
|
||||||
: 'territory';
|
: 'territory';
|
||||||
|
|
||||||
const structuralTypeCode = normalizeStructuralType(selectedType?.code);
|
|
||||||
const structuralTypeName = normalizeStructuralType(selectedType?.name);
|
|
||||||
const compactStructuralSummary = editing && (
|
const compactStructuralSummary = editing && (
|
||||||
['instalacion', 'instalacion-superficie', 'instalacion_de_superficie', 'instalacion-de-superficie', 'subinstalacion']
|
['instalacion', 'instalacion-superficie', 'instalacion_de_superficie', 'instalacion-de-superficie', 'subinstalacion']
|
||||||
.includes(structuralTypeCode)
|
.includes(structuralTypeCode)
|
||||||
@@ -462,24 +505,13 @@ export function AssetEditorPage() {
|
|||||||
|
|
||||||
<div className="form-grid asset-compact-primary-grid">
|
<div className="form-grid asset-compact-primary-grid">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Nombre técnico</span>
|
<span>Nombre habitual <em>obligatorio</em></span>
|
||||||
<input
|
<input value={commonName} onChange={(event) => setCommonName(event.target.value)} disabled={!canEdit} required maxLength={200} placeholder="Nombre usado habitualmente en campo" />
|
||||||
value={name}
|
<small>Es el nombre que se mostrará en el Acta.</small>
|
||||||
onChange={(event) => setName(event.target.value)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
required
|
|
||||||
maxLength={200}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Nombre habitual / sobrenombre <em>opcional</em></span>
|
<span>Nombre técnico <em>opcional</em></span>
|
||||||
<input
|
<input value={name} onChange={(event) => setName(event.target.value)} disabled={!canEdit} maxLength={200} placeholder="Denominación técnica, si corresponde" />
|
||||||
value={commonName}
|
|
||||||
onChange={(event) => setCommonName(event.target.value)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
maxLength={200}
|
|
||||||
placeholder="Ej.: planta vieja, celda principal…"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -628,6 +660,15 @@ export function AssetEditorPage() {
|
|||||||
</label>}
|
</label>}
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
|
{technicalInventory && <label className="field">
|
||||||
|
<span>Función <em>opcional</em></span>
|
||||||
|
<SearchableSelect value={functionId} onChange={(event) => setFunctionId(event.target.value)} disabled={!canEdit}>
|
||||||
|
<option value="">Sin función asignada</option>
|
||||||
|
{functionCatalog.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
|
</SearchableSelect>
|
||||||
|
{hasPermission('asset_types.manage') && <small><Link to="/admin/inventory-functions" className="text-link">Administrar Catálogo de funciones</Link></small>}
|
||||||
|
</label>}
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Descripción <em>opcional</em></span>
|
<span>Descripción <em>opcional</em></span>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -671,21 +712,24 @@ export function AssetEditorPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Nombre técnico</span>
|
<span>{technicalInventory ? <>Nombre habitual <em>obligatorio</em></> : 'Nombre'}</span>
|
||||||
<input value={name} onChange={(event) => setName(event.target.value)} disabled={!canEdit} required maxLength={200} />
|
<input value={technicalInventory ? commonName : name} onChange={(event) => technicalInventory ? setCommonName(event.target.value) : setName(event.target.value)} disabled={!canEdit} required maxLength={200} placeholder={technicalInventory ? 'Nombre usado habitualmente en campo' : undefined} />
|
||||||
</label>
|
{technicalInventory && <small>Es el nombre que se mostrará en el Acta.</small>}
|
||||||
<label className="field">
|
|
||||||
<span>Nombre habitual / sobrenombre <em>opcional</em></span>
|
|
||||||
<input
|
|
||||||
value={commonName}
|
|
||||||
onChange={(event) => setCommonName(event.target.value)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
maxLength={200}
|
|
||||||
placeholder="Ej.: tanque grande, ET vieja, batería norte…"
|
|
||||||
/>
|
|
||||||
<small>También se usa en las búsquedas del Inventario.</small>
|
|
||||||
</label>
|
</label>
|
||||||
|
{technicalInventory && <label className="field">
|
||||||
|
<span>Nombre técnico <em>opcional</em></span>
|
||||||
|
<input value={name} onChange={(event) => setName(event.target.value)} disabled={!canEdit} maxLength={200} placeholder="Denominación técnica, si corresponde" />
|
||||||
|
</label>}
|
||||||
</div>
|
</div>
|
||||||
|
{technicalInventory && <label className="field">
|
||||||
|
<span>Función <em>opcional</em></span>
|
||||||
|
<SearchableSelect value={functionId} onChange={(event) => setFunctionId(event.target.value)} disabled={!canEdit}>
|
||||||
|
<option value="">Sin función asignada</option>
|
||||||
|
{functionCatalog.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
|
</SearchableSelect>
|
||||||
|
{hasPermission('asset_types.manage') && <small><Link to="/admin/inventory-functions" className="text-link">Administrar Catálogo de funciones</Link></small>}
|
||||||
|
</label>}
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Descripción <em>opcional</em></span>
|
<span>Descripción <em>opcional</em></span>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export function DashboardPage() {
|
|||||||
<div className="quick-links">
|
<div className="quick-links">
|
||||||
<Link to="/inventarios"><Icon name="layers" /><span><strong>Inventarios</strong><small>Instancias reales organizadas por empresa, área y jerarquía</small></span><Icon name="chevron" /></Link>
|
<Link to="/inventarios"><Icon name="layers" /><span><strong>Inventarios</strong><small>Instancias reales organizadas por empresa, área y jerarquía</small></span><Icon name="chevron" /></Link>
|
||||||
{hasPermission('asset_types.read') && <Link to="/admin/asset-types"><Icon name="layers" /><span><strong>Configuración de Inventarios</strong><small>Jerarquía, tipos y campos configurables</small></span><Icon name="chevron" /></Link>}
|
{hasPermission('asset_types.read') && <Link to="/admin/asset-types"><Icon name="layers" /><span><strong>Configuración de Inventarios</strong><small>Jerarquía, tipos y campos configurables</small></span><Icon name="chevron" /></Link>}
|
||||||
|
{hasPermission('asset_types.manage') && <Link to="/admin/inventory-functions"><Icon name="layers" /><span><strong>Catálogo de funciones</strong><small>Funciones disponibles para Instalaciones y Subinstalaciones</small></span><Icon name="chevron" /></Link>}
|
||||||
{hasPermission('finding_catalog.manage') && <Link to="/admin/finding-catalog"><Icon name="alert" /><span><strong>Catálogo de hallazgos</strong><small>Hallazgos aplicables según el tipo de Subinstalación</small></span><Icon name="chevron" /></Link>}
|
{hasPermission('finding_catalog.manage') && <Link to="/admin/finding-catalog"><Icon name="alert" /><span><strong>Catálogo de hallazgos</strong><small>Hallazgos aplicables según el tipo de Subinstalación</small></span><Icon name="chevron" /></Link>}
|
||||||
{hasPermission('users.read') && <Link to="/admin/users"><Icon name="users" /><span><strong>Usuarios</strong><small>Accesos y roles</small></span><Icon name="chevron" /></Link>}
|
{hasPermission('users.read') && <Link to="/admin/users"><Icon name="users" /><span><strong>Usuarios</strong><small>Accesos y roles</small></span><Icon name="chevron" /></Link>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function InventoryFunctionsPage() {
|
|||||||
setName('');
|
setName('');
|
||||||
setDescription('');
|
setDescription('');
|
||||||
setSortOrder('0');
|
setSortOrder('0');
|
||||||
setSuccess('Función incorporada al catálogo. Ya puede asignarse a Estaciones y Subestaciones.');
|
setSuccess('Función incorporada al catálogo. Ya puede asignarse a Instalaciones y Subinstalaciones.');
|
||||||
load();
|
load();
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
setError(errorMessage(requestError));
|
setError(errorMessage(requestError));
|
||||||
@@ -112,7 +112,7 @@ export function InventoryFunctionsPage() {
|
|||||||
|
|
||||||
return <section className="narrow-section">
|
return <section className="narrow-section">
|
||||||
<div className="page-heading">
|
<div className="page-heading">
|
||||||
<div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Catálogo de funciones</h1><p>Funciones operativas que pueden asumir las Estaciones y Subestaciones. Desactivar una opción nunca borra su uso histórico.</p></div>
|
<div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Catálogo de funciones</h1><p>Funciones que pueden asignarse a Instalaciones y Subinstalaciones. Desactivar una opción nunca borra su uso histórico.</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <Alert>{error}</Alert>}
|
{error && <Alert>{error}</Alert>}
|
||||||
|
|||||||
Reference in New Issue
Block a user