Compare commits

..
Author SHA1 Message Date
ChatGPT DH d623f235d4 feat(inventory): add function catalog and act data
DH V2 CI / Promote verified main to deploy (push) Successful in 9s
DH V2 CI / WEB · typecheck, build (push) Successful in 45s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 6m25s
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
2026-09-17 08:26:19 -03:00
ChatGPT DH 1beba14c3b feat(web): make active tables sortable
DH V2 CI / API · typecheck, tests, build (push) Successful in 53s
Production dependency audit / API · production dependencies (push) Successful in 19s
Production dependency audit / WEB · production dependencies (push) Successful in 16s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m34s
DH V2 CI / Docker / migrations / production images (push) Successful in 2m49s
DH V2 CI / Promote verified main to deploy (push) Successful in 3s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 7m22s
2026-09-16 19:20:41 -03:00
ChatGPT DH fa6ba7f31e fix(web): render operational map as GPS points
DH V2 CI / API · typecheck, tests, build (push) Successful in 40s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m38s
DH V2 CI / Docker / migrations / production images (push) Successful in 1m55s
DH V2 CI / Promote verified main to deploy (push) Successful in 4s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 4m24s
2026-09-16 08:55:54 -03:00
admin a414d0ed36 fix(web): clarify act context and operational map
DH V2 CI / API · typecheck, tests, build (push) Successful in 36s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m37s
DH V2 CI / Docker / migrations / production images (push) Successful in 1m52s
DH V2 CI / Promote verified main to deploy (push) Successful in 4s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 4m11s
2026-09-16 08:29:19 -03:00
admin 0b731b722f docs(android): document F6.14 mobile scope
Production dependency audit / API · production dependencies (push) Successful in 9s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m29s
DH V2 CI / Docker / migrations / production images (push) Successful in 1m15s
DH V2 CI / Promote verified main to deploy (push) Successful in 6s
DH V2 CI / API · typecheck, tests, build (push) Successful in 44s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 6m2s
2026-09-16 07:46:09 -03:00
admin 678b0f1792 ci(android): retry F6.14 artifact upload
DH V2 CI / WEB · typecheck, build (push) Successful in 23s
DH V2 CI / API · typecheck, tests, build (push) Successful in 57s
DH V2 CI / Docker / migrations / production images (push) Skipped
DH V2 CI / Promote verified main to deploy (push) Skipped
2026-09-16 07:43:56 -03:00
admin c71afab9af fix(android): scope mobile inspections to yacimiento
DH V2 CI / API · typecheck, tests, build (push) Successful in 5m16s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 6m13s
DH V2 CI / WEB · typecheck, build (push) Successful in 4m52s
DH V2 CI / Docker / migrations / production images (push) Skipped
DH V2 CI / Promote verified main to deploy (push) Skipped
2026-09-16 07:33:03 -03:00
admin 29208f4e1f release(android): prepare client demo 0.19.12
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / API · typecheck, tests, build (push) Successful in 36s
Production dependency audit / API · production dependencies (push) Successful in 9s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m33s
DH V2 CI / Docker / migrations / production images (push) Successful in 1m3s
DH V2 CI / Promote verified main to deploy (push) Successful in 2s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 4m5s
2026-09-16 00:42:29 -03:00
admin 4523aef932 fix(map): serve MapLibre module worker as JavaScript
DH V2 CI / WEB · typecheck, build (push) Successful in 23s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
Production dependency audit / API · production dependencies (push) Successful in 49s
DH V2 CI / Docker / migrations / production images (push) Successful in 50s
DH V2 CI / API · typecheck, tests, build (push) Successful in 1m9s
DH V2 CI / Promote verified main to deploy (push) Successful in 2s
2026-09-16 00:32:03 -03:00
admin 011b3e7fcb fix(web): include map worker build script in image
Production dependency audit / API · production dependencies (push) Successful in 48s
DH V2 CI / API · typecheck, tests, build (push) Successful in 1m10s
DH V2 CI / Docker / migrations / production images (push) Successful in 59s
DH V2 CI / Promote verified main to deploy (push) Successful in 2s
DH V2 CI / WEB · typecheck, build (push) Successful in 23s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
2026-09-16 00:23:57 -03:00
admin 9b7d67bea2 test(web): align F6.13 presentation phase
DH V2 CI / WEB · typecheck, build (push) Successful in 23s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
Production dependency audit / API · production dependencies (push) Successful in 49s
DH V2 CI / API · typecheck, tests, build (push) Successful in 1m11s
DH V2 CI / Docker / migrations / production images (push) Failing after 1m1s
DH V2 CI / Promote verified main to deploy (push) Skipped
2026-09-16 00:18:53 -03:00
admin f0b92c00e1 fix(map): publish MapLibre worker
DH V2 CI / WEB · typecheck, build (push) Successful in 21s
DH V2 CI / API · typecheck, tests, build (push) Failing after 46s
DH V2 CI / Docker / migrations / production images (push) Skipped
DH V2 CI / Promote verified main to deploy (push) Skipped
2026-09-16 00:14:17 -03:00
admin cab9cd7c01 fix: restore map geometries and SMTP configuration
DH V2 CI / API · typecheck, tests, build (push) Successful in 38s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m36s
DH V2 CI / Docker / migrations / production images (push) Successful in 1m45s
DH V2 CI / Promote verified main to deploy (push) Successful in 3s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 4m5s
2026-09-15 23:58:49 -03:00
admin c9575cc520 feat(informes): manage manual GEDO responses and shared due date
DH V2 CI / WEB · typecheck, build (push) Successful in 27s
Production dependency audit / API · production dependencies (push) Successful in 15s
Production dependency audit / WEB · production dependencies (push) Successful in 15s
DH V2 CI / API · typecheck, tests, build (push) Successful in 2m3s
DH V2 CI / Docker / migrations / production images (push) Successful in 2m58s
DH V2 CI / Promote verified main to deploy (push) Successful in 3s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m55s
2026-09-15 23:35:49 -03:00
109 changed files with 2480 additions and 558 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
name: Android CI / RC name: Android CI / RC
# F6.8 offline/document barrier: lint + real tests + debug artifact + release compile. # F6.14 yacimiento-scope barrier: lint + real tests + debug artifact + release compile.
on: on:
push: push:
@@ -111,7 +111,7 @@ jobs:
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk" cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
sha256sum "$apk" > "${apk}.sha256" sha256sum "$apk" > "${apk}.sha256"
{ {
echo "phase=F6.8" echo "phase=F6.14"
echo "version=$version" echo "version=$version"
echo "versionCode=$code" echo "versionCode=$code"
echo "commit=$GITHUB_SHA" echo "commit=$GITHUB_SHA"
+18 -3
View File
@@ -1,9 +1,9 @@
# DH Inspección Android · release de campo 0.19.11 # DH Inspección Android · release de campo 0.19.13
## Candidata vigente ## Candidata vigente
- `versionName`: **0.19.11**; `versionCode`: **39**. - `versionName`: **0.19.13**; `versionCode`: **41**.
- API: `https://dhv2.korexlabs.com/api/v3/`; compatible con API 0.29.0-9 / WEB 0.23.0-6. - API: `https://dhv2.korexlabs.com/api/v3/`; compatible con API 0.29.0-14 / WEB 0.23.0-10.
- Antes de cerrar el contenido, el inspector escribe una descripción real de lo actuado. - Antes de cerrar el contenido, el inspector escribe una descripción real de lo actuado.
- La descripción se sincroniza antes del bloqueo, también cuando se trabajó sin conexión. - La descripción se sincroniza antes del bloqueo, también cuando se trabajó sin conexión.
- Un acta anterior, ya sellada, conserva el texto originalmente registrado. - Un acta anterior, ya sellada, conserva el texto originalmente registrado.
@@ -161,3 +161,18 @@ Esta candidata requiere conexión. No implementa trabajo offline ni cola persist
- El borrador de Acta nace vacío, sin Inventario y sin urgencia predeterminada. - El borrador de Acta nace vacío, sin Inventario y sin urgencia predeterminada.
- La urgencia se exige al cerrar el Acta y se persiste en la misma operación que la vuelve inmutable. - La urgencia se exige al cerrar el Acta y se persiste en la misma operación que la vuelve inmutable.
- `inspection_acts.urgency` puede quedar pendiente durante el borrador; el bloqueo exige la decisión y calcula el vencimiento desde allí. - `inspection_acts.urgency` puede quedar pendiente durante el borrador; el bloqueo exige la decisión y calcula el vencimiento desde allí.
## F6.14 · alcance territorial móvil
- Una nueva Inspección abierta desde la APK exige seleccionar Departamento → Área → Yacimiento → Operadora.
- 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.
- 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.
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion" applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 39 versionCode = 42
versionName = "0.19.11" 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)
} }
@@ -25,22 +25,36 @@ data class MobilePlanningAssetResponse(
) )
data class OpenMobileInspectionRequest( data class OpenMobileInspectionRequest(
val departmentId: String,
val operationalAreaId: String, val operationalAreaId: String,
val scopeAssetId: String,
val operatorCompanyId: String, val operatorCompanyId: String,
) )
private interface MobileInspectionOpenApi { private interface MobileInspectionOpenApi {
@GET("inspection-visits/mobile/planning-context/areas") @GET("inspection-visits/mobile/planning-context/departments")
suspend fun areas( suspend fun departments(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
): MobilePlanningAssetResponse ): MobilePlanningAssetResponse
@GET("inspection-visits/mobile/planning-context/areas/{areaId}/operators") @GET("inspection-visits/mobile/planning-context/departments/{departmentId}/areas")
suspend fun operators( suspend fun areas(
@Header("Authorization") authorization: String,
@Path("departmentId") departmentId: String,
): MobilePlanningAssetResponse
@GET("inspection-visits/mobile/planning-context/areas/{areaId}/yacimientos")
suspend fun yacimientos(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@Path("areaId") areaId: String, @Path("areaId") areaId: String,
): MobilePlanningAssetResponse ): MobilePlanningAssetResponse
@GET("inspection-visits/mobile/planning-context/yacimientos/{yacimientoId}/operators")
suspend fun operators(
@Header("Authorization") authorization: String,
@Path("yacimientoId") yacimientoId: String,
): MobilePlanningAssetResponse
@POST("inspection-visits/mobile/open") @POST("inspection-visits/mobile/open")
suspend fun open( suspend fun open(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -61,18 +75,31 @@ class MobileInspectionOpenRepository(context: Context) {
.build() .build()
.create(MobileInspectionOpenApi::class.java) .create(MobileInspectionOpenApi::class.java)
suspend fun areas(): MobilePlanningAssetResponse = authorized { session -> suspend fun departments(): MobilePlanningAssetResponse = authorized { session ->
api.areas("Bearer ${session.accessToken}") api.departments("Bearer ${session.accessToken}")
} }
suspend fun operators(areaId: String): MobilePlanningAssetResponse = authorized { session -> suspend fun areas(departmentId: String): MobilePlanningAssetResponse = authorized { session ->
api.operators("Bearer ${session.accessToken}", areaId) api.areas("Bearer ${session.accessToken}", departmentId)
} }
suspend fun open(areaId: String, companyId: String): VisitDetail = authorized { session -> suspend fun yacimientos(areaId: String): MobilePlanningAssetResponse = authorized { session ->
api.yacimientos("Bearer ${session.accessToken}", areaId)
}
suspend fun operators(yacimientoId: String): MobilePlanningAssetResponse = authorized { session ->
api.operators("Bearer ${session.accessToken}", yacimientoId)
}
suspend fun open(
departmentId: String,
areaId: String,
yacimientoId: String,
companyId: String,
): VisitDetail = authorized { session ->
api.open( api.open(
"Bearer ${session.accessToken}", "Bearer ${session.accessToken}",
OpenMobileInspectionRequest(areaId, companyId), OpenMobileInspectionRequest(departmentId, areaId, yacimientoId, companyId),
) )
} }
@@ -89,5 +116,4 @@ class MobileInspectionOpenRepository(context: Context) {
private suspend fun refresh(previous: StoredSession): StoredSession = private suspend fun refresh(previous: StoredSession): StoredSession =
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh) MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
} }
@@ -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")),
@@ -47,19 +47,41 @@ fun MobileHomeScreen(model: MainViewModel) {
val openRepository = remember(context) { MobileInspectionOpenRepository(context) } val openRepository = remember(context) { MobileInspectionOpenRepository(context) }
var showOpen by rememberSaveable { mutableStateOf(false) } var showOpen by rememberSaveable { mutableStateOf(false) }
var departments by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var areas by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) } var areas by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var yacimientos by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var operators by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) } var operators by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var selectedDepartmentId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedAreaId by rememberSaveable { mutableStateOf<String?>(null) } var selectedAreaId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedYacimientoId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedCompanyId by rememberSaveable { mutableStateOf<String?>(null) } var selectedCompanyId by rememberSaveable { mutableStateOf<String?>(null) }
var loadingContext by remember { mutableStateOf(false) } var loadingContext by remember { mutableStateOf(false) }
var opening by remember { mutableStateOf(false) } var opening by remember { mutableStateOf(false) }
var localError by remember { mutableStateOf<String?>(null) } var localError by remember { mutableStateOf<String?>(null) }
fun loadAreas() { fun loadDepartments() {
scope.launch { scope.launch {
loadingContext = true loadingContext = true
localError = null localError = null
runCatching { openRepository.areas().data } runCatching { openRepository.departments().data }
.onSuccess { departments = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
fun chooseDepartment(departmentId: String) {
selectedDepartmentId = departmentId
selectedAreaId = null
selectedYacimientoId = null
selectedCompanyId = null
areas = emptyList()
yacimientos = emptyList()
operators = emptyList()
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.areas(departmentId).data }
.onSuccess { areas = it } .onSuccess { areas = it }
.onFailure { localError = DhRepository.humanError(it) } .onFailure { localError = DhRepository.humanError(it) }
loadingContext = false loadingContext = false
@@ -68,20 +90,39 @@ fun MobileHomeScreen(model: MainViewModel) {
fun chooseArea(areaId: String) { fun chooseArea(areaId: String) {
selectedAreaId = areaId selectedAreaId = areaId
selectedYacimientoId = null
selectedCompanyId = null
yacimientos = emptyList()
operators = emptyList()
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.yacimientos(areaId).data }
.onSuccess { yacimientos = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
fun chooseYacimiento(yacimientoId: String) {
selectedYacimientoId = yacimientoId
selectedCompanyId = null selectedCompanyId = null
operators = emptyList() operators = emptyList()
scope.launch { scope.launch {
loadingContext = true loadingContext = true
localError = null localError = null
runCatching { openRepository.operators(areaId).data } runCatching { openRepository.operators(yacimientoId).data }
.onSuccess { operators = it } .onSuccess {
operators = it
if (it.size == 1) selectedCompanyId = it.first().id
}
.onFailure { localError = DhRepository.humanError(it) } .onFailure { localError = DhRepository.humanError(it) }
loadingContext = false loadingContext = false
} }
} }
LaunchedEffect(showOpen) { LaunchedEffect(showOpen) {
if (showOpen && areas.isEmpty()) loadAreas() if (showOpen && departments.isEmpty()) loadDepartments()
} }
Column(Modifier.fillMaxSize().padding(top = 28.dp)) { Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
@@ -142,11 +183,23 @@ fun MobileHomeScreen(model: MainViewModel) {
) { ) {
Text("Abrir inspección en campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Abrir inspección en campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text( Text(
"Elegí el Área y la Operadora vigente. La inspección se crea autoasignada a vos y queda iniciada con la fecha y hora del servidor.", "Definí el contexto completo antes de abrir el Acta: Departamento → Área → Yacimiento → Operadora. La inspección queda autoasignada a vos y se inicia con la hora del servidor.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Text("1. Área", fontWeight = FontWeight.SemiBold) Text("1. Departamento", fontWeight = FontWeight.SemiBold)
if (loadingContext && departments.isEmpty()) CircularProgressIndicator()
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(departments, key = { it.id }) { department ->
AssistChip(
onClick = { chooseDepartment(department.id) },
label = { Text(if (department.id == selectedDepartmentId) "${department.name}" else department.name) },
)
}
}
if (selectedDepartmentId != null) {
Text("2. Área", fontWeight = FontWeight.SemiBold)
if (loadingContext && areas.isEmpty()) CircularProgressIndicator() if (loadingContext && areas.isEmpty()) CircularProgressIndicator()
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(areas, key = { it.id }) { area -> items(areas, key = { it.id }) { area ->
@@ -156,13 +209,31 @@ fun MobileHomeScreen(model: MainViewModel) {
) )
} }
} }
}
if (selectedAreaId != null) { if (selectedAreaId != null) {
Text("2. Operadora", fontWeight = FontWeight.SemiBold) Text("3. Yacimiento", fontWeight = FontWeight.SemiBold)
if (loadingContext && yacimientos.isEmpty()) {
CircularProgressIndicator()
} else if (yacimientos.isEmpty()) {
Text("No hay Yacimientos disponibles para el Área seleccionada.", color = MaterialTheme.colorScheme.error)
}
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(yacimientos, key = { it.id }) { yacimiento ->
AssistChip(
onClick = { chooseYacimiento(yacimiento.id) },
label = { Text(if (yacimiento.id == selectedYacimientoId) "${yacimiento.name}" else yacimiento.name) },
)
}
}
}
if (selectedYacimientoId != null) {
Text("4. Operadora", fontWeight = FontWeight.SemiBold)
if (loadingContext && operators.isEmpty()) { if (loadingContext && operators.isEmpty()) {
CircularProgressIndicator() CircularProgressIndicator()
} else if (operators.isEmpty()) { } else if (operators.isEmpty()) {
Text("No hay una Operadora vigente para el Área seleccionada.", color = MaterialTheme.colorScheme.error) Text("El Yacimiento no tiene una Operadora válida configurada.", color = MaterialTheme.colorScheme.error)
} }
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(operators, key = { it.id }) { company -> items(operators, key = { it.id }) { company ->
@@ -176,12 +247,14 @@ fun MobileHomeScreen(model: MainViewModel) {
Button( Button(
onClick = { onClick = {
val departmentId = selectedDepartmentId ?: return@Button
val areaId = selectedAreaId ?: return@Button val areaId = selectedAreaId ?: return@Button
val yacimientoId = selectedYacimientoId ?: return@Button
val companyId = selectedCompanyId ?: return@Button val companyId = selectedCompanyId ?: return@Button
scope.launch { scope.launch {
opening = true opening = true
localError = null localError = null
runCatching { openRepository.open(areaId, companyId) } runCatching { openRepository.open(departmentId, areaId, yacimientoId, companyId) }
.onSuccess { opened -> .onSuccess { opened ->
showOpen = false showOpen = false
model.openVisit(opened.id) model.openVisit(opened.id)
@@ -191,9 +264,9 @@ fun MobileHomeScreen(model: MainViewModel) {
} }
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = selectedAreaId != null && selectedCompanyId != null && !opening && !loadingContext, enabled = selectedDepartmentId != null && selectedAreaId != null && selectedYacimientoId != null && selectedCompanyId != null && !opening && !loadingContext,
) { ) {
Text(if (opening) "Abriendo…" else "Abrir inspección ahora") Text(if (opening) "Abriendo…" else "Abrir inspección y continuar al Acta")
} }
} }
} }
@@ -228,6 +301,7 @@ private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
Text(visit.code, fontWeight = FontWeight.Bold) Text(visit.code, fontWeight = FontWeight.Bold)
Text(visitStatusLabelEs(visit.status)) Text(visitStatusLabelEs(visit.status))
} }
Text(visit.scopeAsset?.name ?: "Yacimiento sin definir", fontWeight = FontWeight.SemiBold)
Text(visit.operatorCompany?.name ?: "Operadora sin definir") Text(visit.operatorCompany?.name ?: "Operadora sin definir")
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall) Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
visit.plannedStartAt?.let { visit.plannedStartAt?.let {
@@ -261,7 +261,7 @@ fun ModernMobileActsScreen(
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text( Text(
"Elegí una Instalación o Subinstalación existente, o creala en campo si todavía no está registrada.", "Elegí el Yacimiento, una Instalación o una Subinstalación. El Hallazgo siempre queda dentro del Yacimiento de esta Acta.",
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
@@ -107,7 +107,9 @@ private data class ModernGeoSnapshot(
@Composable @Composable
fun ModernVisitRoot(model: MainViewModel) { fun ModernVisitRoot(model: MainViewModel) {
val visit = model.visit ?: return val visit = model.visit ?: return
var screenName by rememberSaveable(visit.id) { mutableStateOf(ModernVisitScreen.OVERVIEW.name) } var screenName by rememberSaveable(visit.id) {
mutableStateOf(if (visit.status == "IN_PROGRESS") ModernVisitScreen.ACTS.name else ModernVisitScreen.OVERVIEW.name)
}
val screen = runCatching { ModernVisitScreen.valueOf(screenName) }.getOrDefault(ModernVisitScreen.OVERVIEW) val screen = runCatching { ModernVisitScreen.valueOf(screenName) }.getOrDefault(ModernVisitScreen.OVERVIEW)
when (screen) { when (screen) {
@@ -285,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("") }
@@ -302,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 = ""
@@ -354,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) {
@@ -391,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) {
@@ -401,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) }
@@ -441,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,
@@ -821,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 },
@@ -894,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)
@@ -938,7 +1035,7 @@ private fun ModernInventoryBrowse(
OutlinedTextField( OutlinedTextField(
value = search, value = search,
onValueChange = onSearchChange, onValueChange = onSearchChange,
label = { Text("Buscar Instalación o Subinstalación") }, label = { Text("Buscar Yacimiento, Instalación o Subinstalación") },
placeholder = { Text("Nombre, código o dato técnico") }, placeholder = { Text("Nombre, código o dato técnico") },
leadingIcon = { Icon(Icons.Filled.Search, null) }, leadingIcon = { Icon(Icons.Filled.Search, null) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
@@ -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(39, BuildConfig.VERSION_CODE) assertEquals(42, BuildConfig.VERSION_CODE)
assertEquals("0.19.11-debug", BuildConfig.VERSION_NAME) assertEquals("0.19.14-debug", BuildConfig.VERSION_NAME)
} }
@Test @Test
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-11", "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-11", "version": "0.29.0-18",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-11", "version": "0.29.0-18",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
@@ -18,5 +18,6 @@ import { FieldBriefingService } from './field-briefing.service';
FieldBriefingController, FieldBriefingController,
], ],
providers: [ActAdministrationService, FieldBriefingService], providers: [ActAdministrationService, FieldBriefingService],
exports: [ActAdministrationService],
}) })
export class ActAdministrationModule {} export class ActAdministrationModule {}
@@ -114,17 +114,46 @@ export class ActAdministrationService {
return act; return act;
} }
async setDeadline(actId: string, dto: SetActResponseDeadlineDto, principal: AuthPrincipal, request: RequestWithContext) { async setDeadline(
actId: string,
dto: SetActResponseDeadlineDto,
principal: AuthPrincipal,
request: RequestWithContext,
reportId: string | null = null,
) {
const act = await this.ensureClosedAct(actId); const act = await this.ensureClosedAct(actId);
const rows = await this.dataSource.query( return this.dataSource.transaction(async (manager) => {
`INSERT INTO inspection_act_deadline_events (id, act_id, response_due_on, reason, created_by) VALUES ($1,$2,$3,$4,$5) RETURNING id, response_due_on AS "responseDueOn", reason, created_at AS "createdAt"`, const rows = await manager.query(
[randomUUID(), actId, dto.responseDueOn, dto.reason, principal.userId], `INSERT INTO inspection_act_deadline_events (id, act_id, report_id, response_due_on, reason, created_by)
VALUES ($1,$2,$3,$4,$5,$6)
RETURNING id, report_id AS "reportId", response_due_on AS "responseDueOn", reason, created_at AS "createdAt"`,
[randomUUID(), actId, reportId, dto.responseDueOn, dto.reason, principal.userId],
); );
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_RESPONSE_DEADLINE_SET', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, responseDueOn: dto.responseDueOn, reason: dto.reason } }); const projected = await manager.query(
return rows[0]; `UPDATE inspection_findings
SET correction_due_on=$2, updated_by=$3, updated_at=CURRENT_TIMESTAMP
WHERE act_id=$1 AND status<>'VOIDED'
RETURNING id`,
[actId, dto.responseDueOn, principal.userId],
) as Array<{ id: string }>;
await this.audit.record({
actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_RESPONSE_DEADLINE_SET',
entityType: 'inspection_act', entityId: actId, requestId: request.requestId,
afterData: { actCode: act.code, reportId, responseDueOn: dto.responseDueOn, reason: dto.reason },
metadata: { reportId, projectedFindingCount: projected.length, sharedDeadline: true },
}, manager);
return { ...rows[0], projectedFindingCount: projected.length };
});
} }
async addResponse(actId: string, dto: CreateActCompanyResponseDto, file: UploadedActResponseFile | undefined, principal: AuthPrincipal, request: RequestWithContext) { async addResponse(
actId: string,
dto: CreateActCompanyResponseDto,
file: UploadedActResponseFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
reportId: string | null = null,
) {
const act = await this.ensureClosedAct(actId); const act = await this.ensureClosedAct(actId);
let storedName: string | null = null; let storedName: string | null = null;
let sha256: string | null = null; let sha256: string | null = null;
@@ -138,12 +167,12 @@ export class ActAdministrationService {
} }
try { try {
const rows = await this.dataSource.query( const rows = await this.dataSource.query(
`INSERT INTO inspection_act_company_responses (id, act_id, received_on, details, committed_correction_on, contact_name, contact_email, original_name, stored_name, mime_type, size_bytes, sha256, created_by) `INSERT INTO inspection_act_company_responses (id, act_id, report_id, received_on, details, committed_correction_on, contact_name, contact_email, original_name, stored_name, mime_type, size_bytes, sha256, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
RETURNING id, received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", size_bytes AS "sizeBytes", sha256, created_at AS "createdAt"`, RETURNING id, report_id AS "reportId", received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", size_bytes AS "sizeBytes", sha256, created_at AS "createdAt"`,
[randomUUID(), actId, dto.receivedOn, dto.details ?? null, dto.committedCorrectionOn ?? null, dto.contactName ?? null, dto.contactEmail ?? null, file?.originalname ?? null, storedName, file ? 'application/pdf' : null, file?.size ?? null, sha256, principal.userId], [randomUUID(), actId, reportId, dto.receivedOn, dto.details ?? null, dto.committedCorrectionOn ?? null, dto.contactName ?? null, dto.contactEmail ?? null, file?.originalname ?? null, storedName, file ? 'application/pdf' : null, file?.size ?? null, sha256, principal.userId],
); );
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_COMPANY_RESPONSE_RECORDED', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, receivedOn: dto.receivedOn, committedCorrectionOn: dto.committedCorrectionOn ?? null, hasPdf: Boolean(file), sha256 } }); await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_COMPANY_RESPONSE_RECORDED', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, reportId, receivedOn: dto.receivedOn, committedCorrectionOn: dto.committedCorrectionOn ?? null, hasPdf: Boolean(file), sha256 }, metadata: { reportId } });
return rows[0]; return rows[0];
} catch (error) { } catch (error) {
if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined); if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined);
@@ -151,8 +180,13 @@ export class ActAdministrationService {
} }
} }
async responseContent(responseId: string) { async responseContent(responseId: string, reportId?: string) {
const rows = await this.dataSource.query(`SELECT original_name AS "originalName", stored_name AS "storedName", size_bytes AS "sizeBytes" FROM inspection_act_company_responses WHERE id = $1 AND stored_name IS NOT NULL`, [responseId]); const rows = await this.dataSource.query(
`SELECT original_name AS "originalName", stored_name AS "storedName", size_bytes AS "sizeBytes"
FROM inspection_act_company_responses
WHERE id=$1 AND stored_name IS NOT NULL AND ($2::uuid IS NULL OR report_id=$2::uuid)`,
[responseId, reportId ?? null],
);
const row = rows[0]; const row = rows[0];
if (!row) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'PDF de respuesta inexistente.' }); if (!row) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'PDF de respuesta inexistente.' });
const filePath = join(STORAGE, row.storedName); const filePath = join(STORAGE, row.storedName);
@@ -63,3 +63,25 @@ export class MapAssetsController {
return this.geometries.map(query); return this.geometries.map(query);
} }
} }
@Controller('map/context')
export class MapOperationalContextController {
constructor(private readonly geometries: AssetGeometriesService) {}
@Get()
@RequirePermissions('assets.read')
map() {
return this.geometries.mapOperationalContext();
}
}
@Controller('map/documents')
export class MapDocumentsController {
constructor(private readonly geometries: AssetGeometriesService) {}
@Get()
@RequirePermissions('assets.read', 'inspection_acts.read', 'inspection_findings.read')
map() {
return this.geometries.mapDocuments();
}
}
@@ -230,6 +230,8 @@ export class AssetGeometriesService {
geometry: row.geometry, geometry: row.geometry,
properties: { properties: {
id: row.id, id: row.id,
entityId: row.id,
entityKind: 'ASSET',
code: row.code, code: row.code,
name: row.name, name: row.name,
typeId: row.typeId, typeId: row.typeId,
@@ -242,12 +244,230 @@ export class AssetGeometriesService {
accuracyM: row.accuracyM == null ? null : Number(row.accuracyM), accuracyM: row.accuracyM == null ? null : Number(row.accuracyM),
capturedAt: row.capturedAt, capturedAt: row.capturedAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
href: `/inventarios/${row.id}`,
contextLine: row.parentName ? `Depende de ${row.parentName}` : null,
}, },
})), })),
meta: { count: visible.length, truncated }, meta: { count: visible.length, truncated },
}; };
} }
async mapOperationalContext() {
const rows = (await this.dataSource.query(`
WITH RECURSIVE y_tree AS (
SELECT y.id AS yacimiento_id, y.id AS asset_id
FROM assets y
INNER JOIN asset_types y_type ON y_type.id=y.asset_type_id
WHERE lower(y_type.code)='yacimiento'
AND y_type.is_active=true
AND y.information_status<>'INACTIVE'
UNION ALL
SELECT tree.yacimiento_id, child.id
FROM y_tree tree
INNER JOIN assets child ON child.parent_id=tree.asset_id
WHERE child.information_status<>'INACTIVE'
), y_loc AS (
SELECT tree.yacimiento_id,
ST_Centroid(ST_Collect(geometry.geometry)) AS geometry,
MAX(geometry.updated_at) AS updated_at,
COUNT(*)::integer AS source_geometries
FROM y_tree tree
INNER JOIN asset_geometries geometry ON geometry.asset_id=tree.asset_id
GROUP BY tree.yacimiento_id
), base AS (
SELECT
y.id AS yacimiento_id,y.code AS yacimiento_code,y.name AS yacimiento_name,
y.information_status AS yacimiento_status,
area.id AS area_id,area.code AS area_code,area.name AS area_name,
department.id AS department_id,department.code AS department_code,department.name AS department_name,
company.id AS company_id,company.code AS company_code,COALESCE(profile.legal_name,company.name) AS company_name,
loc.geometry,loc.updated_at,loc.source_geometries
FROM y_loc loc
INNER JOIN assets y ON y.id=loc.yacimiento_id
LEFT JOIN assets area ON area.id=y.parent_id
LEFT JOIN assets department ON department.id=area.parent_id
LEFT JOIN assets company ON company.id=y.operator_company_id
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
)
SELECT
'YACIMIENTO:'||base.yacimiento_id::text AS "featureId",
base.yacimiento_id AS "entityId",'YACIMIENTO'::text AS "entityKind",
ST_AsGeoJSON(base.geometry)::jsonb AS geometry,
base.yacimiento_code AS code,base.yacimiento_name AS name,
'Yacimiento'::text AS "typeName",base.yacimiento_status::text AS "informationStatus",
'POINT'::text AS "geometryType",base.updated_at AS "updatedAt",
'/inventarios/'||base.yacimiento_id::text AS href,
concat_ws(' · ',base.department_name,base.area_name,base.company_name) AS "contextLine",
base.department_name AS "departmentName",base.area_name AS "areaName",
base.yacimiento_name AS "yacimientoName",base.company_name AS "companyName",
base.source_geometries AS "sourceGeometries"
FROM base
UNION ALL
SELECT
'COMPANY:'||base.company_id::text||':'||base.yacimiento_id::text AS "featureId",
base.company_id AS "entityId",'COMPANY'::text AS "entityKind",
ST_AsGeoJSON(base.geometry)::jsonb AS geometry,
base.company_code AS code,base.company_name AS name,
'Empresa operadora'::text AS "typeName",'ACTIVE'::text AS "informationStatus",
'POINT'::text AS "geometryType",base.updated_at AS "updatedAt",
'/inventarios/'||base.company_id::text AS href,
concat_ws(' · ','Yacimiento '||base.yacimiento_name,'Área '||base.area_name,base.department_name) AS "contextLine",
base.department_name AS "departmentName",base.area_name AS "areaName",
base.yacimiento_name AS "yacimientoName",base.company_name AS "companyName",
base.source_geometries AS "sourceGeometries"
FROM base
WHERE base.company_id IS NOT NULL
ORDER BY "entityKind","name","code"
`)) as Array<Record<string, unknown>>;
return {
type: 'FeatureCollection' as const,
features: rows.map((row) => ({
type: 'Feature' as const,
id: row.featureId,
geometry: row.geometry,
properties: {
id: row.featureId,
entityId: row.entityId,
entityKind: row.entityKind,
code: row.code,
name: row.name,
typeName: row.typeName,
informationStatus: row.informationStatus,
geometryType: row.geometryType,
updatedAt: row.updatedAt,
href: row.href,
contextLine: row.contextLine,
departmentName: row.departmentName,
areaName: row.areaName,
yacimientoName: row.yacimientoName,
companyName: row.companyName,
sourceGeometries: row.sourceGeometries,
},
})),
meta: { count: rows.length, truncated: false },
};
}
async mapDocuments() {
const rows = (await this.dataSource.query(`
WITH RECURSIVE y_tree AS (
SELECT y.id AS yacimiento_id, y.id AS asset_id
FROM assets y
INNER JOIN asset_types y_type ON y_type.id=y.asset_type_id
WHERE lower(y_type.code)='yacimiento'
AND y_type.is_active=true
AND y.information_status<>'INACTIVE'
UNION ALL
SELECT tree.yacimiento_id, child.id
FROM y_tree tree
INNER JOIN assets child ON child.parent_id=tree.asset_id
WHERE child.information_status<>'INACTIVE'
), y_loc AS (
SELECT tree.yacimiento_id,
ST_Centroid(ST_Collect(geometry.geometry)) AS geometry,
MAX(geometry.updated_at) AS updated_at
FROM y_tree tree
INNER JOIN asset_geometries geometry ON geometry.asset_id=tree.asset_id
GROUP BY tree.yacimiento_id
), finding_rows AS (
SELECT
finding.id,finding.code,finding.title,finding.status,finding.asset_id,
act.id AS act_id,act.code AS act_code,act.occurred_at,
visit.id AS visit_id,visit.code AS visit_code,
target.code AS asset_code,target.name AS asset_name,
target_geometry.geometry AS target_geometry,
tree.yacimiento_id,
y.name AS yacimiento_name,area.name AS area_name,department.name AS department_name,
COALESCE(profile.legal_name,company.name) AS company_name,
loc.geometry AS fallback_geometry,COALESCE(target_geometry.updated_at,loc.updated_at) AS updated_at
FROM inspection_findings finding
INNER JOIN inspection_acts act ON act.id=finding.act_id
INNER JOIN inspection_visits visit ON visit.id=act.visit_id
INNER JOIN assets target ON target.id=finding.asset_id
LEFT JOIN asset_geometries target_geometry ON target_geometry.asset_id=target.id
LEFT JOIN y_tree tree ON tree.asset_id=target.id
LEFT JOIN assets y ON y.id=tree.yacimiento_id
LEFT JOIN assets area ON area.id=y.parent_id
LEFT JOIN assets department ON department.id=area.parent_id
LEFT JOIN assets company ON company.id=y.operator_company_id
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
LEFT JOIN y_loc loc ON loc.yacimiento_id=tree.yacimiento_id
WHERE finding.status<>'VOIDED'
), act_yacimiento AS (
SELECT act.id AS act_id,
COALESCE(
CASE WHEN lower(COALESCE(scope_type.code,''))='yacimiento' THEN scope.id END,
(SELECT fr.yacimiento_id FROM finding_rows fr WHERE fr.act_id=act.id AND fr.yacimiento_id IS NOT NULL ORDER BY fr.id LIMIT 1)
) AS yacimiento_id
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id=act.visit_id
LEFT JOIN assets scope ON scope.id=visit.scope_asset_id
LEFT JOIN asset_types scope_type ON scope_type.id=scope.asset_type_id
WHERE act.status<>'CANCELLED'
)
SELECT
'FINDING:'||finding.id::text AS "featureId",finding.id AS "entityId",'FINDING'::text AS "entityKind",
ST_AsGeoJSON(ST_Centroid(COALESCE(finding.target_geometry,finding.fallback_geometry)))::jsonb AS geometry,
finding.code,finding.title AS name,'Hallazgo'::text AS "typeName",finding.status::text AS "informationStatus",
'POINT'::text AS "geometryType",finding.updated_at AS "updatedAt",
'/hallazgos/'||finding.id::text AS href,
concat_ws(' · ',finding.act_code,finding.asset_name,finding.yacimiento_name,finding.company_name) AS "contextLine",
finding.department_name AS "departmentName",finding.area_name AS "areaName",
finding.yacimiento_name AS "yacimientoName",finding.company_name AS "companyName",
finding.act_code AS "actCode",finding.asset_name AS "assetName"
FROM finding_rows finding
WHERE COALESCE(finding.target_geometry,finding.fallback_geometry) IS NOT NULL
UNION ALL
SELECT
'ACT:'||act.id::text AS "featureId",act.id AS "entityId",'ACT'::text AS "entityKind",
ST_AsGeoJSON(loc.geometry)::jsonb AS geometry,
act.code,act.title AS name,'Acta'::text AS "typeName",act.status::text AS "informationStatus",
'POINT'::text AS "geometryType",COALESCE(loc.updated_at,act.updated_at) AS "updatedAt",
'/inspecciones/actas/'||act.id::text AS href,
concat_ws(' · ',y.name,area.name,COALESCE(profile.legal_name,company.name)) AS "contextLine",
department.name AS "departmentName",area.name AS "areaName",y.name AS "yacimientoName",
COALESCE(profile.legal_name,company.name) AS "companyName",act.code AS "actCode",NULL::text AS "assetName"
FROM inspection_acts act
INNER JOIN act_yacimiento ay ON ay.act_id=act.id
INNER JOIN y_loc loc ON loc.yacimiento_id=ay.yacimiento_id
INNER JOIN assets y ON y.id=ay.yacimiento_id
LEFT JOIN assets area ON area.id=y.parent_id
LEFT JOIN assets department ON department.id=area.parent_id
LEFT JOIN assets company ON company.id=y.operator_company_id
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
WHERE act.status<>'CANCELLED'
ORDER BY "entityKind","code"
`)) as Array<Record<string, unknown>>;
return {
type: 'FeatureCollection' as const,
features: rows.map((row) => ({
type: 'Feature' as const,
id: row.featureId,
geometry: row.geometry,
properties: {
id: row.featureId,
entityId: row.entityId,
entityKind: row.entityKind,
code: row.code,
name: row.name,
typeName: row.typeName,
informationStatus: row.informationStatus,
geometryType: row.geometryType,
updatedAt: row.updatedAt,
href: row.href,
contextLine: row.contextLine,
departmentName: row.departmentName,
areaName: row.areaName,
yacimientoName: row.yacimientoName,
companyName: row.companyName,
actCode: row.actCode,
assetName: row.assetName,
},
})),
meta: { count: rows.length, truncated: false },
};
}
private async requireAsset( private async requireAsset(
manager: EntityManager, manager: EntityManager,
id: string, id: string,
@@ -7,6 +7,8 @@ import { AssetsService } from './assets.service';
import { import {
AssetGeometriesController, AssetGeometriesController,
MapAssetsController, MapAssetsController,
MapDocumentsController,
MapOperationalContextController,
} from './asset-geometries.controller'; } from './asset-geometries.controller';
import { AssetGeometriesService } from './asset-geometries.service'; import { AssetGeometriesService } from './asset-geometries.service';
import { AssetHistoryController } from './asset-history.controller'; import { AssetHistoryController } from './asset-history.controller';
@@ -30,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';
@@ -45,11 +48,14 @@ import { InventoryBrowserService } from './inventory-browser.service';
InventoryStructureController, InventoryStructureController,
InventoryFamilyCatalogController, InventoryFamilyCatalogController,
InventoryTechnicalValuesController, InventoryTechnicalValuesController,
InventoryFunctionController,
InventoryBrowserController, InventoryBrowserController,
InventoryMergeController, InventoryMergeController,
FieldInventoryMergeController, FieldInventoryMergeController,
AssetGeometriesController, AssetGeometriesController,
MapAssetsController, MapAssetsController,
MapOperationalContextController,
MapDocumentsController,
AssetHistoryController, AssetHistoryController,
AssetMediaController, AssetMediaController,
AssetProvenanceController, AssetProvenanceController,
+47 -5
View File
@@ -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,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F611ReportResponseWorkflow1790139000000 implements MigrationInterface {
name = 'F611ReportResponseWorkflow1790139000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events ADD COLUMN IF NOT EXISTS report_id uuid`);
await queryRunner.query(`ALTER TABLE inspection_act_company_responses ADD COLUMN IF NOT EXISTS report_id uuid`);
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events ADD CONSTRAINT fk_act_deadline_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE RESTRICT`);
await queryRunner.query(`ALTER TABLE inspection_act_company_responses ADD CONSTRAINT fk_act_response_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE RESTRICT`);
await queryRunner.query(`CREATE INDEX idx_act_deadline_events_report_created ON inspection_act_deadline_events(report_id, created_at DESC)`);
await queryRunner.query(`CREATE INDEX idx_act_company_responses_report_received ON inspection_act_company_responses(report_id, received_on DESC, created_at DESC)`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION validate_report_act_relation()
RETURNS trigger AS $$
BEGIN
IF NEW.report_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM inspection_reports report
WHERE report.id=NEW.report_id AND report.act_id=NEW.act_id
) THEN
RAISE EXCEPTION 'El Informe no corresponde al Acta indicada';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql
`);
await queryRunner.query(`CREATE TRIGGER trg_act_deadline_report_relation BEFORE INSERT ON inspection_act_deadline_events FOR EACH ROW EXECUTE FUNCTION validate_report_act_relation()`);
await queryRunner.query(`CREATE TRIGGER trg_act_response_report_relation BEFORE INSERT ON inspection_act_company_responses FOR EACH ROW EXECUTE FUNCTION validate_report_act_relation()`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_response_report_relation ON inspection_act_company_responses`);
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_deadline_report_relation ON inspection_act_deadline_events`);
await queryRunner.query(`DROP FUNCTION IF EXISTS validate_report_act_relation()`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_act_company_responses_report_received`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_act_deadline_events_report_created`);
await queryRunner.query(`ALTER TABLE inspection_act_company_responses DROP CONSTRAINT IF EXISTS fk_act_response_report`);
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events DROP CONSTRAINT IF EXISTS fk_act_deadline_report`);
await queryRunner.query(`ALTER TABLE inspection_act_company_responses DROP COLUMN IF EXISTS report_id`);
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events DROP COLUMN IF EXISTS report_id`);
}
}
@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F612AdminReportPermission1790142600000 implements MigrationInterface {
name = 'F612AdminReportPermission1790142600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO role_permissions(role_id, permission_id)
SELECT role.id, permission.id
FROM roles role
JOIN permissions permission ON permission.code='inspection_reports.generate'
WHERE role.code='admin'
ON CONFLICT(role_id, permission_id) DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM role_permissions role_permission
USING roles role, permissions permission
WHERE role_permission.role_id=role.id
AND role_permission.permission_id=permission.id
AND role.code='admin'
AND permission.code='inspection_reports.generate'
`);
}
}
@@ -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)
`);
}
}
@@ -59,9 +59,22 @@ interface ActReportSummary {
code: string; code: string;
status: string; status: string;
pdfStatus: string; pdfStatus: string;
wordStatus: string;
gedoIfIdentifier: string | null;
gedoOfficializedAt: Date | null;
generatedAt: Date; generatedAt: Date;
} }
interface ActTerritorialContext {
department: ActContextAsset | null;
area: ActContextAsset | null;
yacimiento: ActContextAsset | null;
company: ActContextAsset | null;
installations: ActContextAsset[];
subinstallations: ActContextAsset[];
legacyAreaScope: boolean;
}
export interface InspectionActListItem { export interface InspectionActListItem {
id: string; id: string;
visitId: string; visitId: string;
@@ -94,6 +107,7 @@ export interface InspectionActListItem {
findingCount: number; findingCount: number;
companies: ActContextAsset[]; companies: ActContextAsset[];
areas: ActContextAsset[]; areas: ActContextAsset[];
context: ActTerritorialContext;
report: ActReportSummary | null; report: ActReportSummary | null;
createdBy: ActPerson | null; createdBy: ActPerson | null;
updatedBy: ActPerson | null; updatedBy: ActPerson | null;
@@ -547,11 +561,23 @@ export class InspectionActsService {
COALESCE(finding_count.total, 0)::integer AS "findingCount", COALESCE(finding_count.total, 0)::integer AS "findingCount",
COALESCE(context.companies, '[]'::jsonb) AS companies, COALESCE(context.companies, '[]'::jsonb) AS companies,
COALESCE(context.areas, '[]'::jsonb) AS areas, COALESCE(context.areas, '[]'::jsonb) AS areas,
JSONB_BUILD_OBJECT(
'department', context.department,
'area', context.area,
'yacimiento', context.yacimiento,
'company', context.company,
'installations', COALESCE(context.installations, '[]'::jsonb),
'subinstallations', COALESCE(context.subinstallations, '[]'::jsonb),
'legacyAreaScope', context.legacy_area_scope
) AS context,
CASE WHEN report.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( CASE WHEN report.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', report.id, 'id', report.id,
'code', report.code, 'code', report.code,
'status', report.status, 'status', report.status,
'pdfStatus', report.pdf_status, 'pdfStatus', report.pdf_status,
'wordStatus', report.word_status,
'gedoIfIdentifier', report.gedo_if_identifier,
'gedoOfficializedAt', report.gedo_officialized_at,
'generatedAt', report.generated_at 'generatedAt', report.generated_at
) END AS report, ) END AS report,
CASE WHEN creator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( CASE WHEN creator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
@@ -580,10 +606,64 @@ export class InspectionActsService {
) END AS companies, ) END AS companies,
CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY( CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name)
) END AS areas ) END AS areas,
CASE WHEN department.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',department.id,'code',department.code,'name',department.name
) END AS department,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',area.id,'code',area.code,'name',area.name
) END AS area,
CASE WHEN yacimiento.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',yacimiento.id,'code',yacimiento.code,'name',yacimiento.name
) END AS yacimiento,
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',company.id,'code',company.code,'name',company.name
) END AS company,
COALESCE(affected.installations, '[]'::jsonb) AS installations,
COALESCE(affected.subinstallations, '[]'::jsonb) AS subinstallations,
(lower(COALESCE(scope_type.code,''))='area') AS legacy_area_scope
FROM inspection_visits context_visit FROM inspection_visits context_visit
LEFT JOIN assets company ON company.id=context_visit.operator_company_id LEFT JOIN assets company ON company.id=context_visit.operator_company_id
LEFT JOIN assets area ON area.id=context_visit.operational_area_id LEFT JOIN assets area ON area.id=context_visit.operational_area_id
LEFT JOIN assets department ON department.id=area.parent_id
LEFT JOIN assets scope_asset ON scope_asset.id=context_visit.scope_asset_id
LEFT JOIN asset_types scope_type ON scope_type.id=scope_asset.asset_type_id
LEFT JOIN assets yacimiento ON yacimiento.id=CASE
WHEN lower(COALESCE(scope_type.code,''))='yacimiento' THEN scope_asset.id
ELSE NULL
END
LEFT JOIN LATERAL (
SELECT
COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',x.id,'code',x.code,'name',x.name) ORDER BY x.name,x.code)
FROM (
SELECT DISTINCT installation.id, installation.code, installation.name
FROM inspection_findings finding_context
INNER JOIN assets finding_asset ON finding_asset.id=finding_context.asset_id
INNER JOIN asset_types finding_type ON finding_type.id=finding_asset.asset_type_id
LEFT JOIN assets installation ON installation.id=CASE
WHEN lower(finding_type.code)='instalacion' THEN finding_asset.id
WHEN lower(finding_type.code)='subinstalacion' THEN finding_asset.parent_id
ELSE NULL
END
WHERE finding_context.act_id=act.id
AND finding_context.status<>'VOIDED'
AND installation.id IS NOT NULL
) x
), '[]'::jsonb) AS installations,
COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',x.id,'code',x.code,'name',x.name) ORDER BY x.name,x.code)
FROM (
SELECT DISTINCT finding_asset.id, finding_asset.code, finding_asset.name
FROM inspection_findings finding_context
INNER JOIN assets finding_asset ON finding_asset.id=finding_context.asset_id
INNER JOIN asset_types finding_type ON finding_type.id=finding_asset.asset_type_id
WHERE finding_context.act_id=act.id
AND finding_context.status<>'VOIDED'
AND lower(finding_type.code)='subinstalacion'
) x
), '[]'::jsonb) AS subinstallations
) affected ON true
WHERE context_visit.id=act.visit_id WHERE context_visit.id=act.visit_id
) context ON true ) context ON true
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
@@ -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>>;
@@ -41,9 +41,18 @@ interface FindingAssetView {
code: string; code: string;
name: string; name: string;
commonName: string | null; commonName: string | null;
typeCode: string;
typeName: string; typeName: string;
operatorCompany: FindingContextView | null; operatorCompany: FindingContextView | null;
operationalArea: FindingContextView | null; operationalArea: FindingContextView | null;
hierarchy: {
department: FindingContextView | null;
area: FindingContextView | null;
yacimiento: FindingContextView | null;
installation: FindingContextView | null;
subinstallation: FindingContextView | null;
company: FindingContextView | null;
};
} }
type FindingCatalogView = { type FindingCatalogView = {
@@ -809,13 +818,34 @@ export class InspectionFindingsService {
'code', asset.code, 'code', asset.code,
'name', asset.name, 'name', asset.name,
'commonName', asset.common_name, 'commonName', asset.common_name,
'typeCode', asset_type.code,
'typeName', asset_type.name, 'typeName', asset_type.name,
'operatorCompany', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'operatorCompany', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', company.id, 'code', company.code, 'name', company.name 'id', company.id, 'code', company.code, 'name', company.name
) END, ) END,
'operationalArea', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'operationalArea', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', area.id, 'code', area.code, 'name', area.name 'id', area.id, 'code', area.code, 'name', area.name
) END,
'hierarchy', JSONB_BUILD_OBJECT(
'department', CASE WHEN department.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',department.id,'code',department.code,'name',department.name
) END,
'area', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',area.id,'code',area.code,'name',area.name
) END,
'yacimiento', CASE WHEN yacimiento.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',yacimiento.id,'code',yacimiento.code,'name',yacimiento.name
) END,
'installation', CASE WHEN installation.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',installation.id,'code',installation.code,'name',installation.name
) END,
'subinstallation', CASE WHEN subinstallation.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',subinstallation.id,'code',subinstallation.code,'name',subinstallation.name
) END,
'company', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',company.id,'code',company.code,'name',company.name
) END ) END
)
) AS asset, ) AS asset,
CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', catalog.id, 'id', catalog.id,
@@ -861,6 +891,24 @@ export class InspectionFindingsService {
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN assets company ON company.id = asset.operator_company_id LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id LEFT JOIN assets area ON area.id = asset.operational_area_id
LEFT JOIN assets department ON department.id = area.parent_id
LEFT JOIN assets parent_asset ON parent_asset.id = asset.parent_id
LEFT JOIN assets grandparent_asset ON grandparent_asset.id = parent_asset.parent_id
LEFT JOIN assets yacimiento ON yacimiento.id = CASE
WHEN lower(asset_type.code)='yacimiento' THEN asset.id
WHEN lower(asset_type.code)='instalacion' THEN parent_asset.id
WHEN lower(asset_type.code)='subinstalacion' THEN grandparent_asset.id
ELSE NULL
END
LEFT JOIN assets installation ON installation.id = CASE
WHEN lower(asset_type.code)='instalacion' THEN asset.id
WHEN lower(asset_type.code)='subinstalacion' THEN parent_asset.id
ELSE NULL
END
LEFT JOIN assets subinstallation ON subinstallation.id = CASE
WHEN lower(asset_type.code)='subinstalacion' THEN asset.id
ELSE NULL
END
LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id
LEFT JOIN finding_categories category ON category.id = catalog.category_id LEFT JOIN finding_categories category ON category.id = catalog.category_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
@@ -0,0 +1,14 @@
import { Transform } from 'class-transformer';
import { IsDateString, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class SetInspectionReportResponseDeadlineDto {
@IsDateString()
responseDueOn!: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@IsString()
@MinLength(3)
@MaxLength(1000)
reason?: string | null;
}
@@ -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ó');
@@ -11,6 +11,8 @@ import {
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { DataSource, EntityManager } from 'typeorm'; import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit'; import { administrationAuditContext } from '../administration/common/administration-audit';
import { ActAdministrationService, type UploadedActResponseFile } from '../act-administration/act-administration.service';
import type { CreateActCompanyResponseDto } from '../act-administration/dto/create-act-company-response.dto';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { import {
@@ -20,6 +22,7 @@ import {
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto'; import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto'; import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
import type { SetInspectionReportResponseDeadlineDto } from './dto/set-inspection-report-response-deadline.dto';
export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024; export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024;
@@ -56,6 +59,7 @@ export class InspectionReportWorkflowService {
constructor( constructor(
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly administration: ActAdministrationService,
config: ConfigService, config: ConfigService,
) { ) {
const configured = config.get<string>('INSPECTION_REPORT_UPLOAD_ROOT') const configured = config.get<string>('INSPECTION_REPORT_UPLOAD_ROOT')
@@ -220,6 +224,42 @@ export class InspectionReportWorkflowService {
} }
} }
async setResponseDeadline(
reportId: string,
dto: SetInspectionReportResponseDeadlineDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const report = await this.requireOfficializedReport(reportId);
const reason = dto.reason ?? `Vencimiento general de respuestas definido desde el Informe ${report.code}`;
await this.administration.setDeadline(
report.actId,
{ responseDueOn: dto.responseDueOn, reason },
principal,
request,
report.id,
);
return this.getWorkflowView(this.dataSource.manager, reportId);
}
async addCompanyResponse(
reportId: string,
dto: CreateActCompanyResponseDto,
file: UploadedActResponseFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const report = await this.requireOfficializedReport(reportId);
await this.administration.addResponse(report.actId, dto, file, principal, request, report.id);
return this.getWorkflowView(this.dataSource.manager, reportId);
}
async companyResponseContent(reportId: string, responseId: string) {
const report = await this.getReport(this.dataSource.manager, reportId);
if (!report) throw reportNotFound();
return this.administration.responseContent(responseId, reportId);
}
async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> { async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
const [row] = await this.dataSource.query(` const [row] = await this.dataSource.query(`
SELECT SELECT
@@ -300,6 +340,15 @@ export class InspectionReportWorkflowService {
try { try {
return await this.dataSource.transaction(async (manager) => { return await this.dataSource.transaction(async (manager) => {
const report = await this.lockReport(manager, reportId); const report = await this.lockReport(manager, reportId);
if (
(dto.type === 'COMPANY_NOTE' || dto.type === 'COMPANY_DOCUMENT')
&& report.status !== InspectionReportStatus.OFFICIALIZED
) {
throw new ConflictException({
code: 'INSPECTION_REPORT_GEDO_REQUIRED_FOR_COMPANY_RESPONSE',
message: 'Las respuestas de la empresa se registran después de cargar el PDF oficial de GEDO',
});
}
const occurredAt = new Date(dto.occurredAt); const occurredAt = new Date(dto.occurredAt);
await manager.query(` await manager.query(`
INSERT INTO inspection_report_follow_ups ( INSERT INTO inspection_report_follow_ups (
@@ -407,6 +456,18 @@ export class InspectionReportWorkflowService {
return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : ''; return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : '';
} }
private async requireOfficializedReport(reportId: string): Promise<ReportRow> {
const report = await this.getReport(this.dataSource.manager, reportId);
if (!report) throw reportNotFound();
if (report.status !== InspectionReportStatus.OFFICIALIZED) {
throw new ConflictException({
code: 'INSPECTION_REPORT_GEDO_REQUIRED',
message: 'Primero debe cargarse manualmente el PDF oficial de GEDO',
});
}
return report;
}
private async lockReport(manager: EntityManager, id: string): Promise<ReportRow> { private async lockReport(manager: EntityManager, id: string): Promise<ReportRow> {
const [row] = await manager.query(` const [row] = await manager.query(`
SELECT id,act_id AS "actId",visit_id AS "visitId",code,status, SELECT id,act_id AS "actId",visit_id AS "visitId",code,status,
@@ -15,6 +15,8 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { CreateActCompanyResponseDto } from '../act-administration/dto/create-act-company-response.dto';
import { MAX_ACT_RESPONSE_BYTES, type UploadedActResponseFile } from '../act-administration/act-administration.service';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { Response } from 'express'; import type { Response } from 'express';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
@@ -22,6 +24,7 @@ import { CreateInspectionReportFollowUpDto } from './dto/create-inspection-repor
import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto'; import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto';
import { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto'; import { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
import { SetInspectionReportResponseDeadlineDto } from './dto/set-inspection-report-response-deadline.dto';
import { import {
InspectionReportWorkflowService, InspectionReportWorkflowService,
MAX_INSPECTION_REPORT_FILE_BYTES, MAX_INSPECTION_REPORT_FILE_BYTES,
@@ -136,6 +139,49 @@ export class InspectionReportsController {
return this.workflow.officialize(id, dto, file, principal, request); return this.workflow.officialize(id, dto, file, principal, request);
} }
@Patch(':id/response-deadline')
@RequirePermissions('inspection_reports.generate')
setResponseDeadline(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: SetInspectionReportResponseDeadlineDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.workflow.setResponseDeadline(id, dto, principal, request);
}
@Post(':id/company-responses')
@RequirePermissions('inspection_reports.generate')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_ACT_RESPONSE_BYTES, files: 1 },
}))
addCompanyResponse(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: CreateActCompanyResponseDto,
@UploadedFile() file: UploadedActResponseFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.workflow.addCompanyResponse(id, dto, file, principal, request);
}
@Get(':id/company-responses/:responseId/content')
@RequirePermissions('inspection_reports.read')
async companyResponseContent(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Param('responseId', new ParseUUIDPipe({ version: '4' })) responseId: string,
@Res() response: Response,
): Promise<void> {
const item = await this.workflow.companyResponseContent(id, responseId);
const safeName = item.originalName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
response.setHeader('Content-Type', 'application/pdf');
response.setHeader('Content-Length', String(item.sizeBytes));
response.setHeader('Content-Disposition', `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(item.originalName)}`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
await new Promise<void>((resolveSend, rejectSend) => response.sendFile(item.filePath, (error) => error ? rejectSend(error) : resolveSend()));
}
@Get(':id/follow-ups') @Get(':id/follow-ups')
@RequirePermissions('inspection_reports.read') @RequirePermissions('inspection_reports.read')
followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
import { ActAdministrationModule } from '../act-administration/act-administration.module';
import { DocumentDeliveryController } from './document-delivery.controller'; import { DocumentDeliveryController } from './document-delivery.controller';
import { InspectionActPdfService } from './inspection-act-pdf.service'; import { InspectionActPdfService } from './inspection-act-pdf.service';
import { InspectionDeadlineAdminController } from './inspection-deadline-admin.controller'; import { InspectionDeadlineAdminController } from './inspection-deadline-admin.controller';
@@ -12,7 +13,7 @@ import { InspectionReportsService } from './inspection-reports.service';
import { SmtpDeliveryService } from './smtp-delivery.service'; import { SmtpDeliveryService } from './smtp-delivery.service';
@Module({ @Module({
imports: [AuditModule], imports: [AuditModule, ActAdministrationModule],
controllers: [ controllers: [
InspectionReportsController, InspectionReportsController,
InspectionActReportController, InspectionActReportController,
@@ -64,6 +64,16 @@ export interface InspectionReportListItem {
export interface InspectionReportView extends InspectionReportListItem { export interface InspectionReportView extends InspectionReportListItem {
frozenSnapshot: Record<string, unknown>; frozenSnapshot: Record<string, unknown>;
responseDueOn: string | null;
deadlineReason: string | null;
deadlines: Array<{ id: string; reportId: string | null; responseDueOn: string; reason: string; createdAt: Date }>;
companyResponses: Array<{
id: string; reportId: string | null; receivedOn: string; details: string | null; committedCorrectionOn: string | null;
contactName: string | null; contactEmail: string | null; originalName: string | null; sizeBytes: number | null; sha256: string | null; createdAt: Date;
}>;
findings: Array<{
id: string; code: string; title: string; status: string; assetCode: string; assetName: string; responseDueOn: string | null;
}>;
} }
export interface PendingInspectionReportItem { export interface PendingInspectionReportItem {
@@ -491,7 +501,38 @@ export class InspectionReportsService {
'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1', 'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1',
[id], [id],
)) as Array<{ frozenSnapshot: Record<string, unknown> }>; )) as Array<{ frozenSnapshot: Record<string, unknown> }>;
return { ...report, frozenSnapshot: snapshot.frozenSnapshot }; const deadlines = await manager.query(`
SELECT id,report_id AS "reportId",response_due_on AS "responseDueOn",reason,created_at AS "createdAt"
FROM inspection_act_deadline_events
WHERE act_id=$1 AND (report_id IS NULL OR report_id=$2)
ORDER BY created_at DESC,id DESC
`, [report.actId, report.id]) as InspectionReportView['deadlines'];
const currentDeadline = deadlines[0] ?? null;
const companyResponses = await manager.query(`
SELECT id,report_id AS "reportId",received_on AS "receivedOn",details,
committed_correction_on AS "committedCorrectionOn",contact_name AS "contactName",contact_email AS "contactEmail",
original_name AS "originalName",size_bytes::integer AS "sizeBytes",sha256,created_at AS "createdAt"
FROM inspection_act_company_responses
WHERE act_id=$1 AND (report_id IS NULL OR report_id=$2)
ORDER BY received_on DESC,created_at DESC,id DESC
`, [report.actId, report.id]) as InspectionReportView['companyResponses'];
const findings = await manager.query(`
SELECT finding.id,finding.code,finding.title,finding.status,asset.code AS "assetCode",asset.name AS "assetName",
$2::date AS "responseDueOn"
FROM inspection_findings finding
JOIN assets asset ON asset.id=finding.asset_id
WHERE finding.act_id=$1 AND finding.status<>'VOIDED'
ORDER BY finding.finding_number,finding.id
`, [report.actId, currentDeadline?.responseDueOn ?? null]) as InspectionReportView['findings'];
return {
...report,
frozenSnapshot: snapshot.frozenSnapshot,
responseDueOn: currentDeadline?.responseDueOn ?? null,
deadlineReason: currentDeadline?.reason ?? null,
deadlines,
companyResponses,
findings,
};
} }
private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> { private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> {
@@ -1,7 +1,7 @@
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto'; import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
import { connect as connectNet, Socket } from 'node:net'; import { connect as connectNet, Socket } from 'node:net';
import { connect as connectTls, TLSSocket } from 'node:tls'; import { connect as connectTls, TLSSocket } from 'node:tls';
import { Injectable } from '@nestjs/common'; import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { SmtpSecurityMode } from '../database/entities'; import { SmtpSecurityMode } from '../database/entities';
@@ -250,9 +250,9 @@ export class SmtpDeliveryService {
private encryptionKey():Buffer{ private encryptionKey():Buffer{
const raw=this.config.get<string>('SMTP_SETTINGS_MASTER_KEY'); const raw=this.config.get<string>('SMTP_SETTINGS_MASTER_KEY');
if(!raw)throw new Error('SMTP_SETTINGS_MASTER_KEY no configurada'); if(!raw)throw new ServiceUnavailableException({ code:'SMTP_SETTINGS_MASTER_KEY_NOT_CONFIGURED', message:'La clave maestra para proteger credenciales SMTP no está configurada en el servidor' });
const key=/^[0-9a-fA-F]{64}$/.test(raw)?Buffer.from(raw,'hex'):Buffer.from(raw,'base64'); const key=/^[0-9a-fA-F]{64}$/.test(raw)?Buffer.from(raw,'hex'):Buffer.from(raw,'base64');
if(key.length!==32)throw new Error('SMTP_SETTINGS_MASTER_KEY debe contener exactamente 32 bytes'); if(key.length!==32)throw new ServiceUnavailableException({ code:'SMTP_SETTINGS_MASTER_KEY_INVALID', message:'La clave maestra SMTP del servidor tiene un formato inválido' });
return key; return key;
} }
@@ -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()
@@ -1,9 +1,15 @@
import { IsUUID } from 'class-validator'; import { IsUUID } from 'class-validator';
export class OpenMobileInspectionDto { export class OpenMobileInspectionDto {
@IsUUID('4')
departmentId!: string;
@IsUUID('4') @IsUUID('4')
operationalAreaId!: string; operationalAreaId!: string;
@IsUUID('4')
scopeAssetId!: string;
@IsUUID('4') @IsUUID('4')
operatorCompanyId!: string; operatorCompanyId!: string;
} }
@@ -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,
@@ -62,6 +62,67 @@ export class InspectionPlanningHierarchyService {
return { data }; return { data };
} }
async operatorsForYacimiento(yacimientoId: string): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
await this.requireType(yacimientoId, 'yacimiento', 'El Yacimiento seleccionado no es válido');
const data = await this.dataSource.query(`
SELECT company.id, company.code, COALESCE(profile.legal_name,company.name) AS name
FROM assets yacimiento
INNER JOIN assets company ON company.id=yacimiento.operator_company_id
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
WHERE yacimiento.id=$1::uuid
AND yacimiento.information_status<>'INACTIVE'
AND company_type.operational_role='COMPANY'
AND company_type.is_active=true
AND company.information_status<>'INACTIVE'
ORDER BY name, company.code
`, [yacimientoId]) as InspectionPlanningHierarchyItem[];
return { data };
}
async validateMobileSelection(
departmentId: string,
areaId: string,
yacimientoId: string,
companyId: string,
): Promise<void> {
const [row] = await this.dataSource.query(`
SELECT 1
FROM assets department
INNER JOIN asset_types department_type ON department_type.id=department.asset_type_id
INNER JOIN assets area ON area.parent_id=department.id
INNER JOIN asset_types area_type ON area_type.id=area.asset_type_id
INNER JOIN assets yacimiento ON yacimiento.parent_id=area.id
INNER JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
INNER JOIN assets company ON company.id=yacimiento.operator_company_id
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
WHERE department.id=$1::uuid
AND area.id=$2::uuid
AND yacimiento.id=$3::uuid
AND company.id=$4::uuid
AND lower(department_type.code)='departamento'
AND lower(area_type.code)='area'
AND lower(yacimiento_type.code)='yacimiento'
AND department_type.is_active=true
AND area_type.is_active=true
AND yacimiento_type.is_active=true
AND company_type.operational_role='COMPANY'
AND company_type.is_active=true
AND department.information_status<>'INACTIVE'
AND area.information_status<>'INACTIVE'
AND yacimiento.information_status<>'INACTIVE'
AND company.information_status<>'INACTIVE'
AND yacimiento.concession_type_id IS NOT NULL
LIMIT 1
`, [departmentId, areaId, yacimientoId, companyId]) as Array<{ '?column?': number }>;
if (!row) {
throw new BadRequestException({
code: 'INSPECTION_MOBILE_CONTEXT_INVALID',
message: 'La selección debe respetar Departamento → Área → Yacimiento → Operadora',
});
}
}
async operatorsForArea( async operatorsForArea(
areaId: string, areaId: string,
at?: string, at?: string,
@@ -87,21 +87,41 @@ export class InspectionVisitsController {
return this.planningHierarchy.operatorsForArea(areaId, at); return this.planningHierarchy.operatorsForArea(areaId, at);
} }
@Get('mobile/planning-context/areas') @Get('mobile/planning-context/departments')
@RequirePermissions('inspections.execute') @RequirePermissions('inspections.execute')
mobilePlanningAreas(@CurrentAuth() principal: AuthPrincipal) { mobilePlanningDepartments(@CurrentAuth() principal: AuthPrincipal) {
assertMobileInspector(principal); assertMobileInspector(principal);
return this.visits.listPlanningAreas(); return this.planningHierarchy.departments();
} }
@Get('mobile/planning-context/areas/:areaId/operators') @Get('mobile/planning-context/departments/:departmentId/areas')
@RequirePermissions('inspections.execute') @RequirePermissions('inspections.execute')
mobilePlanningOperators( mobilePlanningAreasForDepartment(
@Param('departmentId', new ParseUUIDPipe({ version: '4' })) departmentId: string,
@CurrentAuth() principal: AuthPrincipal,
) {
assertMobileInspector(principal);
return this.planningHierarchy.areasForDepartment(departmentId);
}
@Get('mobile/planning-context/areas/:areaId/yacimientos')
@RequirePermissions('inspections.execute')
mobilePlanningYacimientos(
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string, @Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
@CurrentAuth() principal: AuthPrincipal, @CurrentAuth() principal: AuthPrincipal,
) { ) {
assertMobileInspector(principal); assertMobileInspector(principal);
return this.visits.listPlanningOperators(areaId); return this.planningHierarchy.yacimientosForArea(areaId);
}
@Get('mobile/planning-context/yacimientos/:yacimientoId/operators')
@RequirePermissions('inspections.execute')
mobilePlanningOperatorsForYacimiento(
@Param('yacimientoId', new ParseUUIDPipe({ version: '4' })) yacimientoId: string,
@CurrentAuth() principal: AuthPrincipal,
) {
assertMobileInspector(principal);
return this.planningHierarchy.operatorsForYacimiento(yacimientoId);
} }
@Post('mobile/open') @Post('mobile/open')
@@ -112,10 +132,15 @@ export class InspectionVisitsController {
@Req() request: RequestWithContext, @Req() request: RequestWithContext,
) { ) {
assertMobileInspector(principal); assertMobileInspector(principal);
// Android conserva por compatibilidad el alcance a nivel Área hasta que su await this.planningHierarchy.validateMobileSelection(
// flujo también solicite Yacimiento. No se mezcla con la creación WEB F6.1. dto.departmentId,
const created = await this.visits.create({ dto.operationalAreaId,
dto.scopeAssetId,
dto.operatorCompanyId,
);
const created = await this.planningCreate.create({
operationalAreaId: dto.operationalAreaId, operationalAreaId: dto.operationalAreaId,
scopeAssetId: dto.scopeAssetId,
operatorCompanyId: dto.operatorCompanyId, operatorCompanyId: dto.operatorCompanyId,
plannedStartAt: new Date().toISOString(), plannedStartAt: new Date().toISOString(),
leadInspectorUserId: principal.userId, leadInspectorUserId: principal.userId,
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.29.0-11'; export const API_VERSION = '0.29.0-18';
export const API_PHASE = 'F6.10'; 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"/);
+3 -3
View File
@@ -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.10 release', () => { test('health metadata reports the current F6.18 release', () => {
assert.equal(API_PHASE, 'F6.10'); 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-11'); assert.equal(API_VERSION, '0.29.0-18');
}); });
+2 -2
View File
@@ -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 = 39/); assert.match(gradle, /versionCode = 42/);
assert.match(gradle, /versionName = "0\.19\.11"/); 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',
); );
}); });
@@ -36,33 +36,48 @@ test('F6.1 uses contextual type catalog for Yacimiento and keeps OTROS/add-anoth
assert.match(androidFinding, /También podés registrar otro Hallazgo sobre el mismo Inventario/); assert.match(androidFinding, /También podés registrar otro Hallazgo sobre el mismo Inventario/);
}); });
test('F6.1 exposes mobile planning context and opens an inspection self-assigned to the current inspector', () => { test('F6.14 mobile planning requires Departamento → Área → Yacimiento → Operadora before opening', () => {
const controller = source('src/inspection-visits/inspection-visits.controller.ts'); const controller = source('src/inspection-visits/inspection-visits.controller.ts');
const hierarchy = source('src/inspection-visits/inspection-planning-hierarchy.service.ts');
const dto = source('src/inspection-visits/dto/open-mobile-inspection.dto.ts'); const dto = source('src/inspection-visits/dto/open-mobile-inspection.dto.ts');
assert.match(controller, /@Get\('mobile\/planning-context\/areas'\)/); assert.match(controller, /@Get\('mobile\/planning-context\/departments'\)/);
assert.match(controller, /@Get\('mobile\/planning-context\/areas\/:areaId\/operators'\)/); assert.match(controller, /@Get\('mobile\/planning-context\/departments\/:departmentId\/areas'\)/);
assert.match(controller, /@Get\('mobile\/planning-context\/areas\/:areaId\/yacimientos'\)/);
assert.match(controller, /@Get\('mobile\/planning-context\/yacimientos\/:yacimientoId\/operators'\)/);
assert.match(controller, /@Post\('mobile\/open'\)/); assert.match(controller, /@Post\('mobile\/open'\)/);
assert.match(controller, /@RequirePermissions\('inspections\.execute'\)/); assert.match(controller, /validateMobileSelection/);
assert.match(controller, /assertMobileInspector\(principal\)/); assert.match(controller, /scopeAssetId: dto\.scopeAssetId/);
assert.match(controller, /leadInspectorUserId: principal\.userId/); assert.match(controller, /leadInspectorUserId: principal\.userId/);
assert.match(controller, /plannedStartAt: new Date\(\)\.toISOString\(\)/);
assert.match(controller, /this\.lifecycle\.plan\(created\.id/); assert.match(controller, /this\.lifecycle\.plan\(created\.id/);
assert.match(controller, /this\.lifecycle\.start\(created\.id/); assert.match(controller, /this\.lifecycle\.start\(created\.id/);
assert.match(hierarchy, /Departamento → Área → Yacimiento → Operadora/);
assert.match(dto, /departmentId!: string/);
assert.match(dto, /operationalAreaId!: string/); assert.match(dto, /operationalAreaId!: string/);
assert.match(dto, /scopeAssetId!: string/);
assert.match(dto, /operatorCompanyId!: string/); assert.match(dto, /operatorCompanyId!: string/);
}); });
test('F6.1 Android lets the inspector choose Area and current Operator and open the inspection now', () => { test('F6.14 Android selects the full territorial chain and enters Actas for an active inspection', () => {
const client = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt'); const client = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt');
const home = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt'); const home = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt');
const root = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt');
const acts = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt');
const gate = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt'); const gate = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt');
assert.match(client, /inspection-visits\/mobile\/planning-context\/areas/); assert.match(client, /planning-context\/departments/);
assert.match(client, /inspection-visits\/mobile\/open/); assert.match(client, /departments\/\{departmentId\}\/areas/);
assert.match(home, /Abrir inspección/); assert.match(client, /areas\/\{areaId\}\/yacimientos/);
assert.match(home, /Abrir inspección ahora/); assert.match(client, /yacimientos\/\{yacimientoId\}\/operators/);
assert.match(home, /Operadora vigente/); assert.match(client, /OpenMobileInspectionRequest\(departmentId, areaId, yacimientoId, companyId\)/);
assert.match(home, /1\. Departamento/);
assert.match(home, /2\. Área/);
assert.match(home, /3\. Yacimiento/);
assert.match(home, /4\. Operadora/);
assert.match(home, /Abrir inspección y continuar al Acta/);
assert.match(home, /model\.openVisit\(opened\.id\)/); assert.match(home, /model\.openVisit\(opened\.id\)/);
assert.match(root, /if \(visit\.status == "IN_PROGRESS"\) ModernVisitScreen\.ACTS/);
assert.match(root, /Buscar Yacimiento, Instalación o Subinstalación/);
assert.match(acts, /Elegí el Yacimiento, una Instalación o una Subinstalación/);
assert.match(gate, /else -> MobileHomeScreen\(model\)/); assert.match(gate, /else -> MobileHomeScreen\(model\)/);
}); });
@@ -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\.9/); 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,56 @@
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(), 'src', path), 'utf8');
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', 'src', path), 'utf8');
test('F6.11 keeps GEDO officialization manual and does not activate response deadlines automatically', () => {
const workflow = api('inspection-reports/inspection-report-workflow.service.ts');
const page = web('pages/ReportDetailPage.tsx');
assert.match(workflow, /GEDO oficializa el INF, pero no equivale por sí solo a la notificación/);
assert.doesNotMatch(workflow.slice(workflow.indexOf(' async officialize('), workflow.indexOf(' async setResponseDeadline(')), /setDeadline\(/);
assert.match(page, /GEDO no se consulta automáticamente/);
assert.match(page, /no crea respuestas ni vencimientos automáticamente/);
});
test('F6.11 stores new deadlines and company responses with both Report and Act relation', () => {
const migration = api('database/migrations/1790139000000-f6-11-report-response-workflow.ts');
const administration = api('act-administration/act-administration.service.ts');
assert.match(migration, /inspection_act_deadline_events ADD COLUMN IF NOT EXISTS report_id uuid/);
assert.match(migration, /inspection_act_company_responses ADD COLUMN IF NOT EXISTS report_id uuid/);
assert.match(migration, /report\.id=NEW\.report_id AND report\.act_id=NEW\.act_id/);
assert.doesNotMatch(migration, /UPDATE inspection_act_deadline_events|UPDATE inspection_act_company_responses/);
assert.match(administration, /INSERT INTO inspection_act_deadline_events \(id, act_id, report_id/);
assert.match(administration, /INSERT INTO inspection_act_company_responses \(id, act_id, report_id/);
});
test('F6.11 projects one Act response deadline to every non-voided finding', () => {
const administration = api('act-administration/act-administration.service.ts');
const reports = api('inspection-reports/inspection-reports.service.ts');
assert.match(administration, /UPDATE inspection_findings[\s\S]*SET correction_due_on=\$2[\s\S]*WHERE act_id=\$1 AND status<>'VOIDED'/);
assert.match(administration, /sharedDeadline: true/);
assert.match(reports, /\$2::date AS "responseDueOn"/);
assert.match(reports, /currentDeadline\?\.responseDueOn/);
});
test('F6.11 enables formal responses only after the official GEDO PDF exists', () => {
const workflow = api('inspection-reports/inspection-report-workflow.service.ts');
const controller = api('inspection-reports/inspection-reports.controller.ts');
const page = web('pages/ReportDetailPage.tsx');
assert.match(workflow, /requireOfficializedReport\(reportId\)/);
assert.match(workflow, /Primero debe cargarse manualmente el PDF oficial de GEDO/);
assert.match(controller, /@Post\(':id\/company-responses'\)/);
assert.match(controller, /@Patch\(':id\/response-deadline'\)/);
assert.match(page, /Las respuestas se habilitan después de cargar el PDF oficial de GEDO/);
assert.match(page, /Vencimiento común del Acta/);
});
test('F6.11 separates formal company responses from internal report follow-up notes', () => {
const page = web('pages/ReportDetailPage.tsx');
assert.match(page, /RESPUESTAS DE EMPRESA/);
assert.match(page, /No usar este bloque para respuestas formales de empresa/);
assert.match(page, /<option value="INTERNAL_NOTE">Nota interna<\/option>/);
assert.doesNotMatch(page.slice(page.indexOf('Agregar otro antecedente')), /option value="COMPANY_NOTE"/);
});
@@ -0,0 +1,32 @@
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 web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8');
test('F6.12 uses standard GeoJSON names in WEB and converts only at the API boundary', () => {
const client = web('src/lib/api.ts');
const map = web('src/features/map/DhMap.tsx');
const editor = web('src/features/map/AssetGeometryEditor.tsx');
assert.match(client, /type: 'Point'/);
assert.match(client, /type: 'LineString'/);
assert.match(client, /type: 'Polygon'/);
assert.match(client, /geometryPayload/);
assert.match(map, /geometry\.type\.toLowerCase\(\) !== 'point'/);
assert.match(editor, /setType\(value\.geometryType\)/);
});
test('F6.12 reports missing SMTP encryption configuration explicitly', () => {
const smtp = api('src/inspection-reports/smtp-delivery.service.ts');
assert.match(smtp, /SMTP_SETTINGS_MASTER_KEY_NOT_CONFIGURED/);
assert.match(smtp, /ServiceUnavailableException/);
});
test('F6.12 grants report management to the system administrator role', () => {
const migration = api('src/database/migrations/1790142600000-f6-12-admin-report-permission.ts');
assert.match(migration, /role\.code='admin'/);
assert.match(migration, /inspection_reports\.generate/);
assert.match(migration, /ON CONFLICT\(role_id, permission_id\) DO NOTHING/);
});
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function api(path: string) { return readFileSync(resolve(process.cwd(), path), 'utf8'); }
function web(path: string) { return readFileSync(resolve(process.cwd(), `../web-v2/src/${path}`), 'utf8'); }
test('F6.15 Acta exposes full territorial and affected-inventory context', () => {
const acts = api('src/inspection-acts/inspection-acts.service.ts');
const findings = api('src/inspection-findings/inspection-findings.service.ts');
const page = web('pages/InspectionActEditorPage.tsx');
const media = web('features/inspections/InspectionActMediaPanel.tsx');
for (const field of ['department', 'area', 'yacimiento', 'company', 'installations', 'subinstallations']) {
assert.match(acts, new RegExp(`'${field}'`));
}
assert.match(acts, /legacyAreaScope/);
assert.match(findings, /'hierarchy'/);
assert.match(findings, /'installation'/);
assert.match(findings, /'subinstallation'/);
assert.match(page, /Ubicación territorial y operativa/);
assert.match(page, /No definido en la Inspección histórica/);
assert.match(media, /Elemento afectado:/);
assert.match(media, /Constatación:/);
});
test('F6.15 Acta uses real report workflow state instead of legacy generated PDF status', () => {
const acts = api('src/inspection-acts/inspection-acts.service.ts');
const page = web('pages/InspectionActEditorPage.tsx');
const list = web('pages/ActsPage.tsx');
assert.match(acts, /'gedoIfIdentifier'/);
assert.match(acts, /'gedoOfficializedAt'/);
assert.match(acts, /'wordStatus'/);
assert.match(page, /act\.report\.status === 'OFFICIALIZED'/);
assert.match(page, /Oficializado en GEDO/);
assert.doesNotMatch(page, /act\.report\.pdfStatus === 'READY'/);
assert.match(list, /act\.report\.status === 'OFFICIALIZED'/);
});
test('F6.16 map renders operational locations as GPS point markers without filters', () => {
const controller = api('src/asset-master/asset-geometries.controller.ts');
const service = api('src/asset-master/asset-geometries.service.ts');
const page = web('pages/MapPage.tsx');
const map = web('features/map/DhMap.tsx');
assert.match(controller, /@Controller\('map\/context'\)/);
assert.match(controller, /@Controller\('map\/documents'\)/);
assert.match(service, /WITH RECURSIVE y_tree/);
assert.match(service, /ST_Centroid\(ST_Collect\(geometry\.geometry\)\)/);
assert.match(service, /COALESCE\(finding\.target_geometry,finding\.fallback_geometry\)/);
for (const kind of ['YACIMIENTO', 'COMPANY', 'ACT', 'FINDING']) {
assert.match(service, new RegExp(`'${kind}'::text`));
assert.match(page, new RegExp(`${kind}:`));
}
assert.match(page, /punto\{data\.meta\.count === 1 \? '' : 's'\} GPS/);
assert.match(page, /feature\.geometry\.type\.toLowerCase\(\) === 'point'/);
assert.match(page, /map-legend/);
assert.doesNotMatch(page, /SearchableSelect/);
assert.doesNotMatch(page, /mapSearch/);
assert.doesNotMatch(page, /toggleLayer/);
assert.match(map, /new Marker/);
assert.match(map, /pointCoordinates/);
assert.match(map, /visualOffsets/);
assert.match(map, /fitToPoints/);
assert.doesNotMatch(map, /addSource\('assets'/);
assert.doesNotMatch(map, /addLayer\(/);
});
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function collectTsx(directory: string): string[] {
return readdirSync(directory).flatMap((name) => {
const path = resolve(directory, name);
return statSync(path).isDirectory() ? collectTsx(path) : name.endsWith('.tsx') ? [path] : [];
});
}
test('F6.17 uses one sortable table component for every active WEB table', () => {
const webRoot = resolve(process.cwd(), '..', 'web-v2', 'src');
const files = collectTsx(webRoot);
const rawTables = files.filter((path) => !path.endsWith('components/SortableTable.tsx') && readFileSync(path, 'utf8').includes('<table'));
assert.deepEqual(rawTables, []);
const sortableFiles = files.filter((path) => readFileSync(path, 'utf8').includes('<SortableTable'));
assert.equal(sortableFiles.length, 24);
const tableCount = sortableFiles.reduce((count, path) => count + (readFileSync(path, 'utf8').match(/<SortableTable/g)?.length ?? 0), 0);
assert.equal(tableCount, 28);
});
test('F6.17 sortable headers toggle direction and compare text, dates and numbers', () => {
const source = readFileSync(resolve(process.cwd(), '..', 'web-v2', 'src', 'components', 'SortableTable.tsx'), 'utf8');
assert.match(source, /aria-sort/);
assert.match(source, /ascending/);
assert.match(source, /descending/);
assert.match(source, /dateValue/);
assert.match(source, /numericPattern/);
assert.match(source, /localeCompare\(right, 'es-AR'/);
assert.match(source, /data-sortable/);
});
@@ -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/);
});
+19
View File
@@ -0,0 +1,19 @@
# F6.11 · GEDO y respuestas de Informes
## Regla funcional
GEDO no está integrado como respuesta automática. El Informe permanece en preparación hasta que un usuario carga manualmente el identificador IF, la fecha y el PDF oficial emitido por GEDO.
La oficialización documental no crea una respuesta de empresa ni activa un vencimiento por sí sola.
## Flujo
1. El Acta sellada origina un INF editable.
2. El usuario carga manualmente IF + PDF oficial GEDO.
3. El Informe queda oficializado e inmutable en su contenido técnico.
4. Desde el Informe se define un único vencimiento de respuesta para su Acta.
5. Ese vencimiento se proyecta a todos los Hallazgos no anulados del Acta.
6. Las respuestas de empresa se registran después de la oficialización y quedan relacionadas con `report_id` + `act_id`.
7. Cada respuesta puede incluir fecha de recepción, detalle, compromiso, contacto y PDF.
Los vencimientos y respuestas históricas permanecen append-only y no se reescriben para completar relaciones nuevas.
+7
View File
@@ -0,0 +1,7 @@
# F6.12 · Geometrías y correo SMTP
- Corrige el contrato GeoJSON entre PostGIS, WEB y MapLibre: `Point`, `LineString` y `Polygon` se usan para render; `POINT`, `LINESTRING` y `POLYGON` quedan como tipos técnicos al persistir.
- El editor de geometrías vuelve a cargar correctamente geometrías existentes y conserva el tipo técnico separado del GeoJSON.
- La vista general del mapa vuelve a calcular bounds sobre GeoJSON real.
- El SMTP administrable requiere una clave maestra AES-256-GCM persistente; si falta o es inválida, el API devuelve un error de configuración explícito en vez de un 500 genérico.
- El rol Administrador recibe `inspection_reports.generate`, coherente con su definición de administración total.
@@ -0,0 +1,8 @@
# F6.14 · Apertura móvil por Yacimiento
La APK abre nuevas Inspecciones únicamente después de seleccionar el contexto completo:
Departamento → Área → Yacimiento → Operadora.
El Yacimiento seleccionado se persiste como `scopeAssetId` de la Inspección. La API valida toda la cadena territorial y que la Operadora corresponda al Yacimiento antes de crear e iniciar la Inspección.
Una Inspección en curso entra directamente al espacio de Actas. Los Hallazgos pueden registrarse únicamente sobre el Yacimiento de alcance, sus Instalaciones o sus Subinstalaciones. Ningún elemento fuera de esa rama puede incorporarse al trabajo de campo.
+23
View File
@@ -0,0 +1,23 @@
# F6.15 · Contexto documental y mapa operativo
## Actas
- El detalle de Acta expone Departamento, Área, Yacimiento y Empresa/Operadora.
- Las Instalaciones y Subinstalaciones mostradas son únicamente las que tienen Hallazgos en esa Acta.
- Cada Hallazgo explica el elemento afectado y su ruta territorial/técnica completa.
- Las Inspecciones históricas cuyo alcance quedó guardado como Área se identifican como tales; el sistema no inventa un Yacimiento.
## Informe relacionado
- El estado visible del Informe se obtiene del workflow documental real.
- `OFFICIALIZED` se presenta como oficializado en GEDO y muestra el identificador IF cuando existe.
- `wordStatus=READY` indica que el INF está listo para remitir a GEDO; `pdfStatus` ya no determina si el Informe está "en preparación".
## Mapa
- Capas independientes: Inventario GPS, Yacimientos, Empresas, Actas y Hallazgos.
- Incluye buscador transversal y conserva filtros de Inventario por tipo, estado y geometría.
- Un Yacimiento sin geometría propia sólo se ubica cuando alguna Instalación/Subinstalación descendiente tiene geometría real; se utiliza el centroide de las geometrías registradas.
- La Empresa se representa como presencia operativa en el Yacimiento que opera, no como una sede inventada.
- Hallazgos usan la geometría exacta del elemento afectado cuando existe y, como respaldo, la ubicación derivada del Yacimiento.
- Actas usan la ubicación derivada de su Yacimiento. Los registros sin referencia geográfica real no se dibujan.
+9
View File
@@ -0,0 +1,9 @@
# F6.16 · Mapa GPS puntual
La vista Mapa representa exclusivamente ubicaciones puntuales GPS.
- Se retiran temporalmente filtros, buscador y activación de capas.
- Se muestran siempre Inventario GPS, Yacimientos, Empresas, Actas y Hallazgos disponibles.
- El render usa marcadores DOM de MapLibre en vez de capas GeoJSON, evitando depender del procesamiento del worker para visualizar puntos.
- Las coordenadas siguen siendo las registradas; si varios registros comparten GPS, sólo se desplazan visualmente algunos píxeles para poder distinguirlos.
- El mapa se encuadra automáticamente sobre todos los puntos válidos.
+9
View File
@@ -0,0 +1,9 @@
# F6.17 · Tablas ordenables
La WEB utiliza un único componente `SortableTable` para las 28 tablas activas.
- Las cabeceras con texto alternan orden ascendente y descendente.
- Las columnas sin título (acciones) no son ordenables.
- El comparador reconoce texto, números y fechas visibles.
- El estado del orden se conserva mientras la tabla permanezca montada.
- El patrón aplica también a tablas de detalle, historial, auditoría, documentación e importaciones.
+5
View File
@@ -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.
+1
View File
@@ -4,6 +4,7 @@ COPY package*.json ./
RUN npm ci RUN npm ci
COPY tsconfig*.json vite.config.ts index.html ./ COPY tsconfig*.json vite.config.ts index.html ./
COPY public ./public COPY public ./public
COPY scripts ./scripts
COPY src ./src COPY src ./src
RUN npm run build RUN npm run build
+5
View File
@@ -4,6 +4,11 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
location = /assets/maplibre-gl-worker.mjs {
default_type application/javascript;
try_files $uri =404;
}
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-7", "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-7", "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",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-7", "version": "0.23.0-14",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
@@ -8,7 +8,7 @@
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build && node scripts/copy-maplibre-worker.mjs",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
+11
View File
@@ -0,0 +1,11 @@
import { chmodSync, copyFileSync, mkdirSync, statSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
const source = resolve('node_modules/maplibre-gl/dist/maplibre-gl-worker.mjs');
const target = resolve('dist/assets/maplibre-gl-worker.mjs');
mkdirSync(dirname(target), { recursive: true });
copyFileSync(source, target);
chmodSync(target, 0o644);
const bytes = statSync(target).size;
if (bytes < 1000) throw new Error(`MapLibre worker inválido: ${bytes} bytes`);
console.log(`MapLibre worker: ${target} (${bytes} bytes)`);
+2
View File
@@ -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 />} />
+119
View File
@@ -0,0 +1,119 @@
import {
Children,
cloneElement,
isValidElement,
useMemo,
useState,
type ReactElement,
type ReactNode,
type TableHTMLAttributes,
} from 'react';
type SortDirection = 'ascending' | 'descending';
type SortState = { column: number; direction: SortDirection } | null;
type ElementProps = Record<string, unknown> & { children?: ReactNode; colSpan?: number };
function textValue(node: ReactNode): string {
if (node == null || typeof node === 'boolean') return '';
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(textValue).join(' ');
if (!isValidElement(node)) return '';
const props = node.props as ElementProps;
const explicit = props['data-sort-value'];
if (explicit != null) return String(explicit);
return textValue(props.children);
}
function dateValue(value: string): number | null {
const normalized = value.replace(/\s+/g, ' ').trim();
const local = normalized.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})(?:,?\s+(\d{1,2}):(\d{2}))?/);
if (local) {
const dayText = local[1]!;
const monthText = local[2]!;
const yearText = local[3]!;
const hourText = local[4] ?? '0';
const minuteText = local[5] ?? '0';
const year = Number(yearText.length === 2 ? `20${yearText}` : yearText);
return Date.UTC(year, Number(monthText) - 1, Number(dayText), Number(hourText), Number(minuteText));
}
if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) {
const parsed = Date.parse(normalized);
return Number.isNaN(parsed) ? null : parsed;
}
return null;
}
function compareValues(leftRaw: string, rightRaw: string): number {
const left = leftRaw.replace(/\s+/g, ' ').trim();
const right = rightRaw.replace(/\s+/g, ' ').trim();
if (!left && !right) return 0;
if (!left) return 1;
if (!right) return -1;
const leftDate = dateValue(left);
const rightDate = dateValue(right);
if (leftDate != null && rightDate != null) return leftDate - rightDate;
const numericPattern = /^[-+]?\d+(?:[.,]\d+)?$/;
if (numericPattern.test(left) && numericPattern.test(right)) {
return Number(left.replace(',', '.')) - Number(right.replace(',', '.'));
}
return left.localeCompare(right, 'es-AR', { numeric: true, sensitivity: 'base' });
}
function rowValue(row: ReactNode, column: number): string {
if (!isValidElement(row)) return '';
const cells = Children.toArray((row.props as ElementProps).children);
return textValue(cells[column]);
}
function sortBody(body: ReactElement, sort: SortState): ReactElement {
if (!sort) return body;
const props = body.props as ElementProps;
const rows = Children.toArray(props.children).map((row, index) => ({ row, index }));
rows.sort((left, right) => {
const compared = compareValues(rowValue(left.row, sort.column), rowValue(right.row, sort.column));
if (compared === 0) return left.index - right.index;
return sort.direction === 'ascending' ? compared : -compared;
});
return cloneElement(body, undefined, rows.map((entry) => entry.row));
}
export function SortableTable({ children, ...props }: TableHTMLAttributes<HTMLTableElement>) {
const [sort, setSort] = useState<SortState>(null);
const renderedChildren = useMemo(() => Children.toArray(children).map((child) => {
if (!isValidElement(child)) return child;
if (child.type === 'tbody') return sortBody(child, sort);
if (child.type !== 'thead') return child;
const headProps = child.props as ElementProps;
const rows = Children.toArray(headProps.children).map((row) => {
if (!isValidElement(row) || row.type !== 'tr') return row;
const rowProps = row.props as ElementProps;
const headers = Children.toArray(rowProps.children).map((header, column) => {
if (!isValidElement(header) || header.type !== 'th') return header;
const headerProps = header.props as ElementProps;
const label = textValue(headerProps.children).trim();
const sortable = Boolean(label) && headerProps['data-sortable'] !== false && (headerProps.colSpan ?? 1) === 1;
if (!sortable) return header;
const active = sort?.column === column;
const direction = active ? sort.direction : undefined;
const nextDirection: SortDirection = active && sort.direction === 'ascending' ? 'descending' : 'ascending';
const headerElement = header as ReactElement<Record<string, unknown>>;
return cloneElement(headerElement, {
'aria-sort': direction ?? 'none',
className: [String(headerProps.className ?? ''), 'sortable-th', active ? 'is-sorted' : ''].filter(Boolean).join(' '),
}, <button type="button" className="sortable-th-button" onClick={() => setSort({ column, direction: nextDirection })}>
<span>{headerProps.children}</span>
<span className="sort-indicator" aria-hidden="true">{active ? (sort.direction === 'ascending' ? '▲' : '▼') : '↕'}</span>
</button>);
});
return cloneElement(row, undefined, headers);
});
return cloneElement(child, undefined, rows);
}), [children, sort]);
return <table {...props}>{renderedChildren}</table>;
}
+2 -2
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.23.0-7'; export const APP_VERSION = '0.23.0-14';
export const APP_PHASE = 'F6.9 · Actas e informes consolidados'; export const APP_PHASE = 'F6.18 · Catálogo de funciones';
@@ -1,3 +1,4 @@
import { SortableTable } from '../../components/SortableTable';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { useAuth } from '../../auth/AuthContext'; import { useAuth } from '../../auth/AuthContext';
@@ -225,7 +226,7 @@ export function AssetDossierPanel({ assetId }: { assetId: string }) {
<article className="panel dossier-wide"> <article className="panel dossier-wide">
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS</span><h2>Seguimiento del elemento</h2></div><Link className="text-link" to="/hallazgos">Ver bandeja general <Icon name="chevron" size={14} /></Link></div> <div className="panel-heading"><div><span className="eyebrow">HALLAZGOS</span><h2>Seguimiento del elemento</h2></div><Link className="text-link" to="/hallazgos">Ver bandeja general <Icon name="chevron" size={14} /></Link></div>
{dossier.findings.length === 0 ? <EmptyState title="Sin hallazgos" text="Este elemento todavía no registra hallazgos." /> : <div className="table-scroll"><table><thead><tr><th>Hallazgo</th><th>Estado</th><th>Acta</th><th>Vencimiento empresa</th><th>Verificación</th><th /></tr></thead><tbody>{dossier.findings.map((finding) => <tr key={finding.id}><td><strong>{finding.title}</strong><small className="block-muted">{finding.code}</small></td><td><span className={`status-badge ${finding.status === 'OPEN' ? 'observed' : 'active'}`}>{findingStatusLabel(finding.status)}</span></td><td><Link className="text-link" to={`/inspecciones/actas/${finding.actId}`}>{finding.actCode}</Link></td><td>{formatDateOnly(finding.correctionDueOn)}</td><td>{formatDateOnly(finding.nextControlOn)}</td><td className="action-cell"><Link className="icon-button" to={`/hallazgos/${finding.id}`} aria-label={`Abrir ${finding.code}`}><Icon name="chevron" /></Link></td></tr>)}</tbody></table></div>} {dossier.findings.length === 0 ? <EmptyState title="Sin hallazgos" text="Este elemento todavía no registra hallazgos." /> : <div className="table-scroll"><SortableTable><thead><tr><th>Hallazgo</th><th>Estado</th><th>Acta</th><th>Vencimiento empresa</th><th>Verificación</th><th /></tr></thead><tbody>{dossier.findings.map((finding) => <tr key={finding.id}><td><strong>{finding.title}</strong><small className="block-muted">{finding.code}</small></td><td><span className={`status-badge ${finding.status === 'OPEN' ? 'observed' : 'active'}`}>{findingStatusLabel(finding.status)}</span></td><td><Link className="text-link" to={`/inspecciones/actas/${finding.actId}`}>{finding.actCode}</Link></td><td>{formatDateOnly(finding.correctionDueOn)}</td><td>{formatDateOnly(finding.nextControlOn)}</td><td className="action-cell"><Link className="icon-button" to={`/hallazgos/${finding.id}`} aria-label={`Abrir ${finding.code}`}><Icon name="chevron" /></Link></td></tr>)}</tbody></SortableTable></div>}
</article> </article>
</div>} </div>}
@@ -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({
@@ -1,3 +1,4 @@
import { SortableTable } from '../../components/SortableTable';
import { SearchableSelect } from '../../components/SearchableSelect'; import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
@@ -121,7 +122,7 @@ export function AssetOperationalRelationsPanel({
<div className="form-actions"><button className="button secondary" disabled={busy || !candidateId || reason.trim().length < 3}><Icon name="plus" />{busy ? 'Guardando…' : 'Vincular'}</button></div> <div className="form-actions"><button className="button secondary" disabled={busy || !candidateId || reason.trim().length < 3}><Icon name="plus" />{busy ? 'Guardando…' : 'Vincular'}</button></div>
</form>} </form>}
{relations.length === 0 ? <EmptyState title="Sin relaciones registradas" text={role === 'AREA' ? 'Todavía no hay organizaciones vinculadas con esta área.' : 'Todavía no hay áreas vinculadas con esta organización.'} /> : <div className="table-scroll"><table><thead><tr><th>{role === 'AREA' ? 'Organización' : 'Área'}</th><th>Rol</th><th>Vigencia</th><th>Motivo</th><th>Registros asignados</th><th>Estado</th><th /></tr></thead><tbody>{relations.map((relation) => { {relations.length === 0 ? <EmptyState title="Sin relaciones registradas" text={role === 'AREA' ? 'Todavía no hay organizaciones vinculadas con esta área.' : 'Todavía no hay áreas vinculadas con esta organización.'} /> : <div className="table-scroll"><SortableTable><thead><tr><th>{role === 'AREA' ? 'Organización' : 'Área'}</th><th>Rol</th><th>Vigencia</th><th>Motivo</th><th>Registros asignados</th><th>Estado</th><th /></tr></thead><tbody>{relations.map((relation) => {
const counterpart = role === 'AREA' ? relation.company : relation.area; const counterpart = role === 'AREA' ? relation.company : relation.area;
return <tr key={relation.id}> return <tr key={relation.id}>
<td><strong className="table-primary">{counterpart.name}</strong><small className="cell-subtext">{counterpart.code} · {counterpart.typeName}</small></td> <td><strong className="table-primary">{counterpart.name}</strong><small className="cell-subtext">{counterpart.code} · {counterpart.typeName}</small></td>
@@ -133,6 +134,6 @@ export function AssetOperationalRelationsPanel({
? <div className="relation-end-inline"><input value={endReason} onChange={(event) => setEndReason(event.target.value)} placeholder="Motivo de finalización" minLength={3} maxLength={1000} /><button type="button" className="button danger-outline compact" disabled={busy || endReason.trim().length < 3} onClick={() => endRelation(relation.id)}>Confirmar</button><button type="button" className="button text compact" onClick={() => { setEndingId(null); setEndReason(''); }}>Cancelar</button></div> ? <div className="relation-end-inline"><input value={endReason} onChange={(event) => setEndReason(event.target.value)} placeholder="Motivo de finalización" minLength={3} maxLength={1000} /><button type="button" className="button danger-outline compact" disabled={busy || endReason.trim().length < 3} onClick={() => endRelation(relation.id)}>Confirmar</button><button type="button" className="button text compact" onClick={() => { setEndingId(null); setEndReason(''); }}>Cancelar</button></div>
: <button type="button" className="button danger-outline compact" onClick={() => { setEndingId(relation.id); setEndReason(''); }}>Finalizar</button>)}</td> : <button type="button" className="button danger-outline compact" onClick={() => { setEndingId(relation.id); setEndReason(''); }}>Finalizar</button>)}</td>
</tr>; </tr>;
})}</tbody></table></div>} })}</tbody></SortableTable></div>}
</article>; </article>;
} }
@@ -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">
@@ -1,3 +1,4 @@
import { SortableTable } from '../../components/SortableTable';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback'; import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
@@ -76,7 +77,7 @@ export function FieldBriefingPanel({ visit }: { visit: InspectionVisit }) {
<div><strong>{act.actCode}</strong><span>{formatDate(act.occurredAt)} · {act.findings.length} hallazgo{act.findings.length === 1 ? '' : 's'}</span></div> <div><strong>{act.actCode}</strong><span>{formatDate(act.occurredAt)} · {act.findings.length} hallazgo{act.findings.length === 1 ? '' : 's'}</span></div>
<span className={`status-badge ${stateClass(act.adminState)}`}>{stateLabel(act.adminState)}</span> <span className={`status-badge ${stateClass(act.adminState)}`}>{stateLabel(act.adminState)}</span>
</div> </div>
<div className="table-scroll"><table> <div className="table-scroll"><SortableTable>
<thead><tr><th>Hallazgo</th><th>Inventario</th><th>Gravedad</th><th>Control</th><th /></tr></thead> <thead><tr><th>Hallazgo</th><th>Inventario</th><th>Gravedad</th><th>Control</th><th /></tr></thead>
<tbody>{act.findings.map((finding) => <tr key={finding.id}> <tbody>{act.findings.map((finding) => <tr key={finding.id}>
<td><Link className="history-asset-link" to={`/hallazgos/${finding.id}`}><strong>{finding.title}</strong><small>{finding.code} · {finding.status}</small></Link></td> <td><Link className="history-asset-link" to={`/hallazgos/${finding.id}`}><strong>{finding.title}</strong><small>{finding.code} · {finding.status}</small></Link></td>
@@ -85,7 +86,7 @@ export function FieldBriefingPanel({ visit }: { visit: InspectionVisit }) {
<td>{finding.nextControlOn ? formatDateOnly(finding.nextControlOn) : 'A definir'}</td> <td>{finding.nextControlOn ? formatDateOnly(finding.nextControlOn) : 'A definir'}</td>
<td className="action-cell"><Link className="button secondary compact" to={`/seguimiento-actas/${act.actId}`}>Ver Acta</Link></td> <td className="action-cell"><Link className="button secondary compact" to={`/seguimiento-actas/${act.actId}`}>Ver Acta</Link></td>
</tr>)}</tbody> </tr>)}</tbody>
</table></div> </SortableTable></div>
<div className="table-summary"> <div className="table-summary">
<span>Plazo empresa: <strong>{act.responseDueOn ? formatDateOnly(act.responseDueOn) : 'sin definir'}</strong></span> <span>Plazo empresa: <strong>{act.responseDueOn ? formatDateOnly(act.responseDueOn) : 'sin definir'}</strong></span>
<span>Respuesta: <strong>{act.responseReceivedOn ? formatDateOnly(act.responseReceivedOn) : 'pendiente'}</strong></span> <span>Respuesta: <strong>{act.responseReceivedOn ? formatDateOnly(act.responseReceivedOn) : 'pendiente'}</strong></span>
@@ -30,9 +30,18 @@ function Photo({ id, title, caption, load }: { id: string; title: string; captio
function Finding({ item }: { item: FindingWithPhotos }) { function Finding({ item }: { item: FindingWithPhotos }) {
const { finding, photos } = item; const { finding, photos } = item;
const hierarchy = finding.asset.hierarchy;
return <article className="act-finding-record"> return <article className="act-finding-record">
<div className="inspection-finding-heading"><div><span className="eyebrow">{finding.code}</span><h3>{finding.title}</h3><p>{finding.asset.name} · {finding.asset.code}</p></div></div> <div className="inspection-finding-heading"><div><span className="eyebrow">{finding.code}</span><h3>{finding.title}</h3><p><strong>Elemento afectado:</strong> {finding.asset.typeName} · {finding.asset.name} · {finding.asset.code}</p></div></div>
<p className="inspection-finding-description">{finding.description}</p> {hierarchy && <div className="responsible-summary act-finding-context">
<div><small>Departamento</small><strong>{hierarchy.department?.name ?? '—'}</strong></div>
<div><small>Área</small><strong>{hierarchy.area?.name ?? '—'}</strong></div>
<div><small>Yacimiento</small><strong>{hierarchy.yacimiento?.name ?? '—'}</strong></div>
<div><small>Empresa</small><strong>{hierarchy.company?.name ?? '—'}</strong></div>
<div><small>Instalación</small><strong>{hierarchy.installation?.name ?? 'No corresponde'}</strong></div>
<div><small>Subinstalación</small><strong>{hierarchy.subinstallation?.name ?? 'No corresponde'}</strong></div>
</div>}
<p className="inspection-finding-description"><strong>Constatación:</strong> {finding.description}</p>
{finding.legalBasis && <p><strong>Normativa:</strong> {finding.legalBasis}</p>} {finding.legalBasis && <p><strong>Normativa:</strong> {finding.legalBasis}</p>}
{finding.severity != null && <p>Gravedad {finding.severity}/10</p>} {finding.severity != null && <p>Gravedad {finding.severity}/10</p>}
{photos.length > 0 && <div className="act-finding-photos"> {photos.length > 0 && <div className="act-finding-photos">
@@ -1,3 +1,4 @@
import { SortableTable } from '../../components/SortableTable';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { useAuth } from '../../auth/AuthContext'; import { useAuth } from '../../auth/AuthContext';
@@ -37,7 +38,7 @@ export function InspectionActsPanel({ visit }: { visit: InspectionVisit }) {
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Cargando actas…" /> : acts.length === 0 {loading ? <LoadingBlock label="Cargando actas…" /> : acts.length === 0
? <EmptyState title="Sin actas sincronizadas" text={visit.status === 'IN_PROGRESS' ? 'El inspector puede crear la primera acta desde la APK.' : 'Aparecerán aquí a medida que el inspector genere actas desde la APK.'} /> ? <EmptyState title="Sin actas sincronizadas" text={visit.status === 'IN_PROGRESS' ? 'El inspector puede crear la primera acta desde la APK.' : 'Aparecerán aquí a medida que el inspector genere actas desde la APK.'} />
: <div className="table-panel inspection-acts-table"><div className="table-summary"><strong>Actas de la inspección</strong><span>Numeración oficial global por año</span></div><div className="table-scroll"><table><thead><tr><th>Acta</th><th>Estado</th><th>Fecha</th><th>Registros</th><th>Hallazgos</th><th>Versión</th><th /></tr></thead><tbody>{acts.map((act) => <tr key={act.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{act.title}</strong><small>{act.code}</small></div></div></td><td><span className={`status-badge ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span></td><td>{formatDate(act.occurredAt)}</td><td>{act.assetCount}</td><td>{act.findingCount}</td><td>v{act.currentVersion}</td><td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><Icon name="chevron" /></Link></td></tr>)}</tbody></table></div></div>} : <div className="table-panel inspection-acts-table"><div className="table-summary"><strong>Actas de la inspección</strong><span>Numeración oficial global por año</span></div><div className="table-scroll"><SortableTable><thead><tr><th>Acta</th><th>Estado</th><th>Fecha</th><th>Registros</th><th>Hallazgos</th><th>Versión</th><th /></tr></thead><tbody>{acts.map((act) => <tr key={act.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{act.title}</strong><small>{act.code}</small></div></div></td><td><span className={`status-badge ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span></td><td>{formatDate(act.occurredAt)}</td><td>{act.assetCount}</td><td>{act.findingCount}</td><td>v{act.currentVersion}</td><td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><Icon name="chevron" /></Link></td></tr>)}</tbody></SortableTable></div></div>}
</section>} </section>}
</>; </>;
} }
@@ -41,22 +41,22 @@ function localDateTime(value: string | number | Date): string {
function geometryVertices(geometry: GeoJsonGeometry | null): Position[] { function geometryVertices(geometry: GeoJsonGeometry | null): Position[] {
if (!geometry) return []; if (!geometry) return [];
if (geometry.type === 'POINT') return [geometry.coordinates]; if (geometry.type === 'Point') return [geometry.coordinates];
if (geometry.type === 'LINESTRING') return geometry.coordinates; if (geometry.type === 'LineString') return geometry.coordinates;
return geometry.coordinates[0]?.slice(0, -1) ?? []; return geometry.coordinates[0]?.slice(0, -1) ?? [];
} }
function draftGeometry(type: AssetGeometryType, vertices: Position[]): GeoJsonGeometry | null { function draftGeometry(type: AssetGeometryType, vertices: Position[]): GeoJsonGeometry | null {
if (type === 'POINT') return vertices[0] ? { type, coordinates: vertices[0] } : null; if (type === 'POINT') return vertices[0] ? { type: 'Point', coordinates: vertices[0] } : null;
if (type === 'LINESTRING') return vertices.length >= 2 ? { type, coordinates: vertices } : null; if (type === 'LINESTRING') return vertices.length >= 2 ? { type: 'LineString', coordinates: vertices } : null;
if (vertices.length < 3) return null; if (vertices.length < 3) return null;
return { type, coordinates: [[...vertices, vertices[0]!]] }; return { type: 'Polygon', coordinates: [[...vertices, vertices[0]!]] };
} }
function drawingCollection(geometry: GeoJsonGeometry | null, vertices: Position[]) { function drawingCollection(geometry: GeoJsonGeometry | null, vertices: Position[]) {
const features: unknown[] = []; const features: unknown[] = [];
if (geometry) features.push({ type: 'Feature', properties: { kind: 'shape' }, geometry }); if (geometry) features.push({ type: 'Feature', properties: { kind: 'shape' }, geometry });
if (geometry?.type !== 'POINT') { if (geometry?.type !== 'Point') {
vertices.forEach((coordinates, index) => features.push({ vertices.forEach((coordinates, index) => features.push({
type: 'Feature', properties: { kind: 'vertex', index: index + 1 }, type: 'Feature', properties: { kind: 'vertex', index: index + 1 },
geometry: { type: 'Point', coordinates }, geometry: { type: 'Point', coordinates },
@@ -187,7 +187,7 @@ export function AssetGeometryEditor({
const applyStored = (value: AssetGeometry | null) => { const applyStored = (value: AssetGeometry | null) => {
setStored(value); setStored(value);
if (value) { if (value) {
setType(value.geometry.type); setType(value.geometryType);
setVertices(geometryVertices(value.geometry)); setVertices(geometryVertices(value.geometry));
setAccuracyM(value.accuracyM == null ? '' : String(value.accuracyM)); setAccuracyM(value.accuracyM == null ? '' : String(value.accuracyM));
setCapturedAt(value.capturedAt ? localDateTime(value.capturedAt) : ''); setCapturedAt(value.capturedAt ? localDateTime(value.capturedAt) : '');
+71 -132
View File
@@ -1,134 +1,80 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { import { Map as MlMap, Marker, NavigationControl } from 'maplibre-gl';
Map, import type { MapAssetFeature, MapAssetFeatureCollection, MapEntityKind } from '../../lib/api';
NavigationControl,
type GeoJSONSource,
type MapGeoJSONFeature,
} from 'maplibre-gl';
import type { MapAssetFeatureCollection } from '../../lib/api';
const osmStyle = { const osmStyle = {
version: 8 as const, version: 8 as const,
sources: { sources: { osm: { type: 'raster' as const, tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, attribution: '© OpenStreetMap contributors' } },
osm: {
type: 'raster' as const,
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
},
},
layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }], layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }],
}; };
const interactiveLayers = ['assets-points', 'assets-lines', 'assets-polygons']; type GpsPoint = { feature: MapAssetFeature; coordinates: [number, number] };
function boundsFromFeatures(collection: MapAssetFeatureCollection) { function pointCoordinates(feature: MapAssetFeature): [number, number] | null {
const positions: Array<[number, number]> = []; const geometry = feature.geometry as { type: string; coordinates: unknown };
collection.features.forEach((feature) => { if (geometry.type.toLowerCase() !== 'point' || !Array.isArray(geometry.coordinates)) return null;
if (feature.geometry.type === 'POINT') positions.push(feature.geometry.coordinates); const [longitude, latitude] = geometry.coordinates;
if (feature.geometry.type === 'LINESTRING') positions.push(...feature.geometry.coordinates); if (typeof longitude !== 'number' || typeof latitude !== 'number') return null;
if (feature.geometry.type === 'POLYGON') feature.geometry.coordinates.forEach((ring) => positions.push(...ring)); if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return null;
}); if (longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) return null;
if (!positions.length) return null; return [longitude, latitude];
return positions.reduce<[number, number, number, number]>((result, point) => [
Math.min(result[0], point[0]),
Math.min(result[1], point[1]),
Math.max(result[2], point[0]),
Math.max(result[3], point[1]),
], [positions[0]![0], positions[0]![1], positions[0]![0], positions[0]![1]]);
} }
export function DhMap({ function gpsPoints(collection: MapAssetFeatureCollection): GpsPoint[] {
data, return collection.features.flatMap((feature) => {
selectedId, const coordinates = pointCoordinates(feature);
onSelect, return coordinates ? [{ feature, coordinates }] : [];
}: { });
}
function fitToPoints(map: MlMap, points: GpsPoint[]) {
if (!points.length) return;
const bounds = points.reduce<[number, number, number, number]>((result, point) => [
Math.min(result[0], point.coordinates[0]), Math.min(result[1], point.coordinates[1]),
Math.max(result[2], point.coordinates[0]), Math.max(result[3], point.coordinates[1]),
], [points[0]!.coordinates[0], points[0]!.coordinates[1], points[0]!.coordinates[0], points[0]!.coordinates[1]]);
if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) map.flyTo({ center: [bounds[0], bounds[1]], zoom: 15 });
else map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 80, maxZoom: 15 });
}
function visualOffsets(points: GpsPoint[]) {
const groups = new Map<string, GpsPoint[]>();
points.forEach((point) => {
const key = `${point.coordinates[0].toFixed(5)}:${point.coordinates[1].toFixed(5)}`;
groups.set(key, [...(groups.get(key) ?? []), point]);
});
const offsets = new Map<string, [number, number]>();
groups.forEach((group) => {
group.forEach((point, index) => {
if (group.length === 1) { offsets.set(String(point.feature.id), [0, 0]); return; }
const angle = (Math.PI * 2 * index) / group.length;
const radius = Math.min(18, 8 + group.length);
offsets.set(String(point.feature.id), [Math.cos(angle) * radius, Math.sin(angle) * radius]);
});
});
return offsets;
}
export function DhMap({ data, selectedId, onSelect }: {
data: MapAssetFeatureCollection; data: MapAssetFeatureCollection;
selectedId: string | null; selectedId: string | null;
onSelect: (id: string | null) => void; onSelect: (id: string | null) => void;
}) { }) {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<Map | null>(null); const mapRef = useRef<MlMap | null>(null);
const markersRef = useRef<Marker[]>([]);
const onSelectRef = useRef(onSelect); const onSelectRef = useRef(onSelect);
const dataRef = useRef(data);
onSelectRef.current = onSelect; onSelectRef.current = onSelect;
dataRef.current = data;
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
const map = new Map({ const map = new MlMap({ container: containerRef.current, style: osmStyle, center: [-68.8458, -32.8895], zoom: 6 });
container: containerRef.current,
style: osmStyle,
center: [-68.8458, -32.8895],
zoom: 6,
});
mapRef.current = map; mapRef.current = map;
map.addControl(new NavigationControl(), 'top-right'); map.addControl(new NavigationControl(), 'top-right');
map.on('click', () => onSelectRef.current(null));
map.on('load', () => {
map.addSource('assets', { type: 'geojson', data: dataRef.current as never });
map.addLayer({
id: 'assets-polygons', type: 'fill', source: 'assets',
filter: ['==', ['geometry-type'], 'Polygon'],
paint: { 'fill-color': '#2864dc', 'fill-opacity': 0.22, 'fill-outline-color': '#184caf' },
});
map.addLayer({
id: 'assets-lines', type: 'line', source: 'assets',
filter: ['==', ['geometry-type'], 'LineString'],
paint: { 'line-color': '#2864dc', 'line-width': 3 },
});
map.addLayer({
id: 'assets-points', type: 'circle', source: 'assets',
filter: ['==', ['geometry-type'], 'Point'],
paint: {
'circle-radius': 7, 'circle-color': '#2864dc',
'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2,
},
});
map.addLayer({
id: 'assets-selected-polygons', type: 'line', source: 'assets',
filter: ['all', ['==', ['geometry-type'], 'Polygon'], ['==', ['get', 'id'], '']],
paint: { 'line-color': '#f59e0b', 'line-width': 4 },
});
map.addLayer({
id: 'assets-selected-lines', type: 'line', source: 'assets',
filter: ['all', ['==', ['geometry-type'], 'LineString'], ['==', ['get', 'id'], '']],
paint: { 'line-color': '#f59e0b', 'line-width': 6 },
});
map.addLayer({
id: 'assets-selected-points', type: 'circle', source: 'assets',
filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'id'], '']],
paint: {
'circle-radius': 10, 'circle-color': '#f59e0b',
'circle-stroke-color': '#ffffff', 'circle-stroke-width': 3,
},
});
const click = (event: { features?: MapGeoJSONFeature[] }) => {
const id = event.features?.[0]?.properties?.id;
onSelectRef.current(typeof id === 'string' ? id : null);
};
interactiveLayers.forEach((layer) => {
map.on('click', layer, click);
map.on('mouseenter', layer, () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', layer, () => { map.getCanvas().style.cursor = ''; });
});
map.on('click', (event) => {
const hits = map.queryRenderedFeatures(event.point, { layers: interactiveLayers });
if (!hits.length) onSelectRef.current(null);
});
const bounds = boundsFromFeatures(dataRef.current);
if (bounds) {
if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) {
map.flyTo({ center: [bounds[0], bounds[1]], zoom: 13 });
} else {
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 55, maxZoom: 15 });
}
}
});
return () => { return () => {
markersRef.current.forEach((marker) => marker.remove());
markersRef.current = [];
mapRef.current = null; mapRef.current = null;
map.remove(); map.remove();
}; };
@@ -136,31 +82,24 @@ export function DhMap({
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map?.isStyleLoaded()) return; if (!map) return;
(map.getSource('assets') as GeoJSONSource | undefined)?.setData(data as never); markersRef.current.forEach((marker) => marker.remove());
const bounds = boundsFromFeatures(data); markersRef.current = [];
if (bounds) { const points = gpsPoints(data);
if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) { const offsets = visualOffsets(points);
map.flyTo({ center: [bounds[0], bounds[1]], zoom: 13 }); points.forEach(({ feature, coordinates }) => {
} else { const kind = (feature.properties.entityKind ?? 'ASSET') as MapEntityKind;
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 55, maxZoom: 15 }); const element = document.createElement('button');
} element.type = 'button';
} element.className = `map-gps-marker kind-${kind.toLowerCase()}${String(feature.id) === selectedId ? ' selected' : ''}`;
}, [data]); element.title = `${feature.properties.typeName}: ${feature.properties.name}`;
element.setAttribute('aria-label', element.title);
useEffect(() => { element.addEventListener('click', (event) => { event.stopPropagation(); onSelectRef.current(String(feature.id)); });
const map = mapRef.current; const marker = new Marker({ element, offset: offsets.get(String(feature.id)) ?? [0, 0] }).setLngLat(coordinates).addTo(map);
if (!map?.isStyleLoaded()) return; markersRef.current.push(marker);
const id = selectedId ?? '__none__';
const filters: Array<[string, string]> = [
['assets-selected-points', 'Point'],
['assets-selected-lines', 'LineString'],
['assets-selected-polygons', 'Polygon'],
];
filters.forEach(([layer, geometryType]) => {
map.setFilter(layer, ['all', ['==', ['geometry-type'], geometryType], ['==', ['get', 'id'], id]]);
}); });
}, [selectedId]); fitToPoints(map, points);
}, [data, selectedId]);
return <div ref={containerRef} className="map-canvas" />; return <div ref={containerRef} className="map-canvas" />;
} }
+1
View File
@@ -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' },
]; ];
+53 -12
View File
@@ -536,9 +536,9 @@ export type Position = [number, number];
export type AssetGeometryType = 'POINT' | 'LINESTRING' | 'POLYGON'; export type AssetGeometryType = 'POINT' | 'LINESTRING' | 'POLYGON';
export type GeoJsonGeometry = export type GeoJsonGeometry =
| { type: 'POINT'; coordinates: Position } | { type: 'Point'; coordinates: Position }
| { type: 'LINESTRING'; coordinates: Position[] } | { type: 'LineString'; coordinates: Position[] }
| { type: 'POLYGON'; coordinates: Position[][] }; | { type: 'Polygon'; coordinates: Position[][] };
export interface AssetGeometry { export interface AssetGeometry {
assetId: string; assetId: string;
@@ -553,21 +553,34 @@ export interface AssetGeometry {
updatedBy: string | null; updatedBy: string | null;
} }
export type MapEntityKind = 'ASSET' | 'YACIMIENTO' | 'COMPANY' | 'ACT' | 'FINDING';
export interface MapAssetProperties { export interface MapAssetProperties {
id: string; id: string;
entityId?: string;
entityKind?: MapEntityKind;
code: string; code: string;
name: string; name: string;
commonName?: string | null; commonName?: string | null;
typeId: string; typeId?: string | null;
typeCode: string; typeCode?: string | null;
typeName: string; typeName: string;
parentId: string | null; parentId?: string | null;
parentName: string | null; parentName?: string | null;
informationStatus: AssetInformationStatus; informationStatus?: AssetInformationStatus | null;
geometryType: AssetGeometryType; geometryType: AssetGeometryType;
accuracyM: number | null; accuracyM?: number | null;
capturedAt: string | null; capturedAt?: string | null;
updatedAt: string; updatedAt: string;
href?: string | null;
contextLine?: string | null;
departmentName?: string | null;
areaName?: string | null;
yacimientoName?: string | null;
companyName?: string | null;
actCode?: string | null;
assetName?: string | null;
sourceGeometries?: number | null;
} }
export interface MapAssetFeature { export interface MapAssetFeature {
@@ -1473,7 +1486,17 @@ export interface InspectionFinding {
currentVersion: number; currentVersion: number;
closedAt: string | null; closedAt: string | null;
closureNotes: string | null; closureNotes: string | null;
asset: InspectionAssetSummary; asset: InspectionAssetSummary & {
typeCode?: string;
hierarchy?: {
department: { id: string; code: string; name: string } | null;
area: { id: string; code: string; name: string } | null;
yacimiento: { id: string; code: string; name: string } | null;
installation: { id: string; code: string; name: string } | null;
subinstallation: { id: string; code: string; name: string } | null;
company: { id: string; code: string; name: string } | null;
};
};
catalog: { catalog: {
id: string; id: string;
code: string; code: string;
@@ -2038,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;
@@ -2162,6 +2186,15 @@ export async function getAssetGeometry(assetId: string) {
return (await apiRequest<{ data: AssetGeometry | null }>(`/assets/${assetId}/geometry`)).data; return (await apiRequest<{ data: AssetGeometry | null }>(`/assets/${assetId}/geometry`)).data;
} }
function geometryPayload(geometry: GeoJsonGeometry) {
const type: AssetGeometryType = geometry.type === 'Point'
? 'POINT'
: geometry.type === 'LineString'
? 'LINESTRING'
: 'POLYGON';
return { type, coordinates: geometry.coordinates };
}
export function upsertAssetGeometry(assetId: string, input: { export function upsertAssetGeometry(assetId: string, input: {
geometry: GeoJsonGeometry; geometry: GeoJsonGeometry;
accuracyM?: number | null; accuracyM?: number | null;
@@ -2169,7 +2202,7 @@ export function upsertAssetGeometry(assetId: string, input: {
deviceLabel?: string | null; deviceLabel?: string | null;
}) { }) {
return apiRequest<AssetGeometry>(`/assets/${assetId}/geometry`, { return apiRequest<AssetGeometry>(`/assets/${assetId}/geometry`, {
method: 'PUT', body: JSON.stringify(input), method: 'PUT', body: JSON.stringify({ ...input, geometry: geometryPayload(input.geometry) }),
}); });
} }
@@ -2179,6 +2212,14 @@ export function removeAssetGeometry(assetId: string) {
}); });
} }
export function getMapOperationalContext() {
return apiRequest<MapAssetFeatureCollection>('/map/context');
}
export function getMapDocuments() {
return apiRequest<MapAssetFeatureCollection>('/map/documents');
}
export function getMapAssets(params: { export function getMapAssets(params: {
bbox?: string; bbox?: string;
typeId?: string; typeId?: string;
+12
View File
@@ -73,11 +73,23 @@ export interface InspectionActListItemF4 {
findingCount: number; findingCount: number;
companies: Array<{ id: string; code: string; name: string }>; companies: Array<{ id: string; code: string; name: string }>;
areas: Array<{ id: string; code: string; name: string }>; areas: Array<{ id: string; code: string; name: string }>;
context: {
department: { id: string; code: string; name: string } | null;
area: { id: string; code: string; name: string } | null;
yacimiento: { id: string; code: string; name: string } | null;
company: { id: string; code: string; name: string } | null;
installations: Array<{ id: string; code: string; name: string }>;
subinstallations: Array<{ id: string; code: string; name: string }>;
legacyAreaScope: boolean;
};
report: null | { report: null | {
id: string; id: string;
code: string; code: string;
status: InspectionReportStatusF4; status: InspectionReportStatusF4;
pdfStatus: InspectionReportPdfStatus; pdfStatus: InspectionReportPdfStatus;
wordStatus: 'PENDING' | 'READY' | 'FAILED';
gedoIfIdentifier: string | null;
gedoOfficializedAt: string | null;
generatedAt: string; generatedAt: string;
}; };
createdBy: InspectionPerson | null; createdBy: InspectionPerson | null;
+1 -1
View File
@@ -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',
+36
View File
@@ -64,6 +64,14 @@ export interface InspectionReportDetailF4 {
companies: Array<{ id: string; code: string; name: string }>; companies: Array<{ id: string; code: string; name: string }>;
areas: Array<{ id: string; code: string; name: string }>; areas: Array<{ id: string; code: string; name: string }>;
findingCount: number; findingCount: number;
responseDueOn: string | null;
deadlineReason: string | null;
deadlines: Array<{ id: string; reportId: string | null; responseDueOn: string; reason: string; createdAt: string }>;
findings: Array<{ id: string; code: string; title: string; status: string; assetCode: string; assetName: string; responseDueOn: string | null }>;
companyResponses: Array<{
id: string; reportId: string | null; receivedOn: string; details: string | null; committedCorrectionOn: string | null;
contactName: string | null; contactEmail: string | null; originalName: string | null; sizeBytes: number | null; sha256: string | null; createdAt: string;
}>;
} }
export interface PendingInspectionReportF4 { export interface PendingInspectionReportF4 {
@@ -176,6 +184,30 @@ export function officializeInspectionReport(
}); });
} }
export function setInspectionReportResponseDeadline(
id: string,
input: { responseDueOn: string; reason?: string | null },
) {
return apiRequest<InspectionReportWorkflowView>(`/inspection-reports/${id}/response-deadline`, {
method: 'PATCH',
body: JSON.stringify(input),
});
}
export function addInspectionReportCompanyResponse(
id: string,
input: { receivedOn: string; details?: string; committedCorrectionOn?: string; contactName?: string; contactEmail?: string; file?: File | null },
) {
const body = new FormData();
body.set('receivedOn', input.receivedOn);
if (input.details?.trim()) body.set('details', input.details.trim());
if (input.committedCorrectionOn) body.set('committedCorrectionOn', input.committedCorrectionOn);
if (input.contactName?.trim()) body.set('contactName', input.contactName.trim());
if (input.contactEmail?.trim()) body.set('contactEmail', input.contactEmail.trim());
if (input.file) body.set('file', input.file);
return apiRequest<InspectionReportWorkflowView>(`/inspection-reports/${id}/company-responses`, { method: 'POST', body });
}
export function addInspectionReportFollowUp( export function addInspectionReportFollowUp(
id: string, id: string,
input: { input: {
@@ -206,6 +238,10 @@ export function inspectionReportGedoPdfDownloadUrl(id: string) {
return `/api/v3/inspection-reports/${id}/gedo-pdf`; return `/api/v3/inspection-reports/${id}/gedo-pdf`;
} }
export function inspectionReportCompanyResponseDownloadUrl(reportId: string, responseId: string) {
return `/api/v3/inspection-reports/${reportId}/company-responses/${responseId}/content`;
}
export function inspectionReportFollowUpDownloadUrl(reportId: string, followUpId: string) { export function inspectionReportFollowUpDownloadUrl(reportId: string, followUpId: string) {
return `/api/v3/inspection-reports/${reportId}/follow-ups/${followUpId}/content`; return `/api/v3/inspection-reports/${reportId}/follow-ups/${followUpId}/content`;
} }
+2 -1
View File
@@ -1,3 +1,4 @@
import { SortableTable } from '../components/SortableTable';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
@@ -18,5 +19,5 @@ export function ActAdministrationPage(){
useEffect(()=>{setLoading(true);setError('');listActAdministrationQueue({state,areaId:ctx.areaId||undefined,companyId:ctx.companyId||undefined,page:1,pageSize:50}).then(r=>{setItems(r.data);setMeta(r.meta);setCounters(r.counters)}).catch(e=>setError(errorMessage(e))).finally(()=>setLoading(false));},[state,ctx.areaId,ctx.companyId]); useEffect(()=>{setLoading(true);setError('');listActAdministrationQueue({state,areaId:ctx.areaId||undefined,companyId:ctx.companyId||undefined,page:1,pageSize:50}).then(r=>{setItems(r.data);setMeta(r.meta);setCounters(r.counters)}).catch(e=>setError(errorMessage(e))).finally(()=>setLoading(false));},[state,ctx.areaId,ctx.companyId]);
return <section><div className="page-heading"><div><span className="eyebrow">SEGUIMIENTO ADMINISTRATIVO</span><h1>Seguimiento de Actas</h1><p>El plazo y la respuesta de la empresa se gestionan sobre el Acta completa. GEDO no activa el vencimiento por solo; el plazo se define cuando corresponda. Los hallazgos quedan dentro de su expediente.</p></div></div> return <section><div className="page-heading"><div><span className="eyebrow">SEGUIMIENTO ADMINISTRATIVO</span><h1>Seguimiento de Actas</h1><p>El plazo y la respuesta de la empresa se gestionan sobre el Acta completa. GEDO no activa el vencimiento por solo; el plazo se define cuando corresponda. Los hallazgos quedan dentro de su expediente.</p></div></div>
<div className="status-tabs">{states.map(s=><button key={s.value} className={state===s.value?'active':''} onClick={()=>setState(s.value)}>{s.label}{s.value!=='ALL'&&<small>{counters[s.value]??0}</small>}</button>)}</div> <div className="status-tabs">{states.map(s=><button key={s.value} className={state===s.value?'active':''} onClick={()=>setState(s.value)}>{s.label}{s.value!=='ALL'&&<small>{counters[s.value]??0}</small>}</button>)}</div>
{error&&<Alert>{error}</Alert>}{loading?<LoadingBlock label="Cargando seguimiento…"/>:items.length===0?<EmptyState title="Sin actas" text="No hay actas en este estado para el contexto seleccionado."/>:<div className="table-panel"><div className="table-summary"><strong>{meta.total} acta{meta.total===1?'':'s'}</strong></div><div className="table-scroll"><table><thead><tr><th>Acta</th><th>Área / empresa</th><th>Hallazgos</th><th>Plazo empresa</th><th>Respuesta</th><th>Estado</th><th/></tr></thead><tbody>{items.map(a=><tr key={a.actId}><td><strong>{a.actCode}</strong><small className="block-muted">{formatDate(a.occurredAt)}</small></td><td><strong>{a.areaName??'—'}</strong><small className="block-muted">{a.companyName??'—'}</small></td><td>{a.openFindingCount} abiertos / {a.findingCount}</td><td>{a.responseDueOn?formatDate(a.responseDueOn):'Sin definir'}</td><td>{a.responseReceivedOn?formatDate(a.responseReceivedOn):'Pendiente'}</td><td><span className={`status-badge ${a.adminState==='OVERDUE'||a.adminState==='COMMITMENT_OVERDUE'?'danger':'pending'}`}>{label(a.adminState)}</span></td><td><Link className="button secondary" to={`/seguimiento-actas/${a.actId}`}>Gestionar</Link></td></tr>)}</tbody></table></div></div>}</section>; {error&&<Alert>{error}</Alert>}{loading?<LoadingBlock label="Cargando seguimiento…"/>:items.length===0?<EmptyState title="Sin actas" text="No hay actas en este estado para el contexto seleccionado."/>:<div className="table-panel"><div className="table-summary"><strong>{meta.total} acta{meta.total===1?'':'s'}</strong></div><div className="table-scroll"><SortableTable><thead><tr><th>Acta</th><th>Área / empresa</th><th>Hallazgos</th><th>Plazo empresa</th><th>Respuesta</th><th>Estado</th><th/></tr></thead><tbody>{items.map(a=><tr key={a.actId}><td><strong>{a.actCode}</strong><small className="block-muted">{formatDate(a.occurredAt)}</small></td><td><strong>{a.areaName??'—'}</strong><small className="block-muted">{a.companyName??'—'}</small></td><td>{a.openFindingCount} abiertos / {a.findingCount}</td><td>{a.responseDueOn?formatDate(a.responseDueOn):'Sin definir'}</td><td>{a.responseReceivedOn?formatDate(a.responseReceivedOn):'Pendiente'}</td><td><span className={`status-badge ${a.adminState==='OVERDUE'||a.adminState==='COMMITMENT_OVERDUE'?'danger':'pending'}`}>{label(a.adminState)}</span></td><td><Link className="button secondary" to={`/seguimiento-actas/${a.actId}`}>Gestionar</Link></td></tr>)}</tbody></SortableTable></div></div>}</section>;
} }
+5 -4
View File
@@ -1,3 +1,4 @@
import { SortableTable } from '../components/SortableTable';
import { SearchableSelect } from '../components/SearchableSelect'; import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
@@ -111,15 +112,15 @@ export function ActsPage() {
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Cargando actas…" /> : items.length === 0 ? <EmptyState title="Sin actas" text="No hay actas para los filtros seleccionados." /> : <div className="table-panel document-table"> {loading ? <LoadingBlock label="Cargando actas…" /> : items.length === 0 ? <EmptyState title="Sin actas" text="No hay actas para los filtros seleccionados." /> : <div className="table-panel document-table">
<div className="table-summary"><strong>{meta.total} acta{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div> <div className="table-summary"><strong>{meta.total} acta{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div>
<div className="table-scroll"><table><thead><tr><th>Acta</th><th>Empresa / área</th><th>Inspección</th><th>Hallazgos</th><th>Estado</th><th>Informe</th><th /></tr></thead><tbody>{items.map((act) => <tr key={act.id}> <div className="table-scroll"><SortableTable><thead><tr><th>Acta</th><th>Empresa / territorio</th><th>Inspección</th><th>Hallazgos</th><th>Estado</th><th>Informe</th><th /></tr></thead><tbody>{items.map((act) => <tr key={act.id}>
<td><div className="document-primary"><strong>{act.code}</strong><small>{formatDate(act.occurredAt)}</small></div></td> <td><div className="document-primary"><strong>{act.code}</strong><small>{formatDate(act.occurredAt)}</small></div></td>
<td><div className="document-primary"><strong>{contextLabel(act.companies, 'Empresa sin asignar')}</strong><small>{contextLabel(act.areas, 'Área sin asignar')}</small></div></td> <td><div className="document-primary"><strong>{act.context.company?.name ?? contextLabel(act.companies, 'Empresa sin asignar')}</strong><small>{[act.context.department?.name, act.context.area?.name, act.context.yacimiento?.name].filter(Boolean).join(' · ') || contextLabel(act.areas, 'Área sin asignar')}</small></div></td>
<td><Link className="text-link" to={`/inspecciones/${act.visitId}`}>{act.visit.code}</Link></td> <td><Link className="text-link" to={`/inspecciones/${act.visitId}`}>{act.visit.code}</Link></td>
<td><Link className="text-link" to={`/hallazgos?search=${encodeURIComponent(act.code)}`}>{act.findingCount}</Link></td> <td><Link className="text-link" to={`/hallazgos?search=${encodeURIComponent(act.code)}`}>{act.findingCount}</Link></td>
<td><span className={`status-badge ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span></td> <td><span className={`status-badge ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span></td>
<td>{act.report ? <Link className="text-link" to={`/informes/${act.report.id}`}>{act.report.code}<small className="block-muted">{act.report.pdfStatus === 'READY' ? 'PDF disponible' : 'PDF pendiente'}</small></Link> : ['SEALED', 'CLOSED'].includes(act.status) ? <span className="status-badge pending">Pendiente de emisión</span> : <span className="muted"></span>}</td> <td>{act.report ? <Link className="text-link" to={`/informes/${act.report.id}`}>{act.report.code}<small className="block-muted">{act.report.status === 'OFFICIALIZED' ? `Oficializado en GEDO${act.report.gedoIfIdentifier ? ` · ${act.report.gedoIfIdentifier}` : ''}` : act.report.wordStatus === 'READY' ? 'INF listo' : 'En preparación'}</small></Link> : ['SEALED', 'CLOSED'].includes(act.status) ? <span className="status-badge pending">Pendiente de emisión</span> : <span className="muted"></span>}</td>
<td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><Icon name="chevron" /></Link></td> <td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><Icon name="chevron" /></Link></td>
</tr>)}</tbody></table></div> </tr>)}</tbody></SortableTable></div>
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div> <div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
</div>} </div>}
</section>; </section>;
+78 -34
View File
@@ -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
+5 -4
View File
@@ -1,3 +1,4 @@
import { SortableTable } from '../components/SortableTable';
import { SearchableSelect } from '../components/SearchableSelect'; import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
@@ -654,9 +655,9 @@ Escribí IMPORTAR para continuar:`);
{PLAN_GROUPS.map((group) => { {PLAN_GROUPS.map((group) => {
const visibleKinds = group.kinds.filter((kind) => Number(record(planByKind[kind])?.total ?? 0) > 0); const visibleKinds = group.kinds.filter((kind) => Number(record(planByKind[kind])?.total ?? 0) > 0);
if (!visibleKinds.length) return null; if (!visibleKinds.length) return null;
return <section key={group.label} className="import-plan-group"><h4>{group.label}</h4><div className="table-scroll"><table><thead><tr><th>Entidad</th><th>Crear nuevos</th><th>Usar existentes</th><th>Revisar</th><th>Ignorar</th><th>Total</th><th></th></tr></thead><tbody>{visibleKinds.map((kind) => { const counts = record(planByKind[kind])!; return <tr key={kind}><td><strong>{ENTITY_LABELS[kind]}</strong></td><td>{Number(counts.create ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.match ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.review ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.ignore ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.total ?? 0).toLocaleString('es-AR')}</td><td><button type="button" className="button secondary small" onClick={() => void loadPlanDetail(kind, 1, null)}>Ver detalle</button></td></tr>; })}</tbody></table></div></section>; return <section key={group.label} className="import-plan-group"><h4>{group.label}</h4><div className="table-scroll"><SortableTable><thead><tr><th>Entidad</th><th>Crear nuevos</th><th>Usar existentes</th><th>Revisar</th><th>Ignorar</th><th>Total</th><th></th></tr></thead><tbody>{visibleKinds.map((kind) => { const counts = record(planByKind[kind])!; return <tr key={kind}><td><strong>{ENTITY_LABELS[kind]}</strong></td><td>{Number(counts.create ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.match ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.review ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.ignore ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.total ?? 0).toLocaleString('es-AR')}</td><td><button type="button" className="button secondary small" onClick={() => void loadPlanDetail(kind, 1, null)}>Ver detalle</button></td></tr>; })}</tbody></SortableTable></div></section>;
})} })}
{(planDetailKind || planDetailAction) && <div className="import-plan-detail"><div className="table-summary"><strong>{planDetailKind ? ENTITY_LABELS[planDetailKind] : planDetailAction ? PLAN_ACTION_LABELS[planDetailAction] : 'Detalle del plan'}</strong><span>{planDetailMeta.total.toLocaleString('es-AR')} elementos únicos</span></div>{planDetailLoading ? <LoadingBlock label="Cargando detalle del plan…" /> : <><div className="table-scroll"><table><thead><tr><th>Decisión</th><th>Entidad / relación</th><th>Qué hará el sistema</th><th>Filas fuente</th></tr></thead><tbody>{planDetailItems.map((item) => { const objectId = matchedObjectId(item); return <tr key={item.id}><td><span className={`status-badge ${item.action === 'REVIEW' ? 'observed' : item.action === 'CREATE' ? 'active' : item.action === 'MATCH' ? 'pending' : 'inactive'}`}>{PLAN_ACTION_LABELS[item.action]}</span></td><td><strong>{item.displayName}</strong><small className="cell-subtle">{ENTITY_LABELS[item.entityKind]}</small>{item.entityKind === 'DEPARTMENT' && <small className="cell-subtle">Código {String(item.payload.departmentCode ?? '—')}</small>}{item.entityKind === 'LEGAL_RIGHT' && <small className="cell-subtle">{String(item.payload.rightType ?? '—')} · estado inicial PENDING</small>}</td><td>{item.action === 'MATCH' ? <div className="import-match-explanation"><strong>Se reutiliza un registro del inventario</strong><span>{MATCH_CRITERIA[item.entityKind]}</span>{objectId && <code>ID {objectId.slice(0, 8)}</code>}</div> : item.action === 'CREATE' ? <div className="import-match-explanation"><strong>Se creará un registro nuevo</strong><span>No se encontró una coincidencia exacta y segura con las reglas de este tipo.</span></div> : item.action === 'REVIEW' ? <div className="row-issue-list">{item.reviewCodes.slice(0, 3).map((code) => <span key={code}>{reviewGuidance(code).title}</span>)}</div> : <span className="cell-subtle">No se incorporará al inventario.</span>}</td><td>{sourceRowsLabel(item.sourceRowNumbers)}</td></tr>; })}</tbody></table></div>{planDetailMeta.totalPages > 1 && <div className="pagination"><button type="button" disabled={planDetailMeta.page <= 1} onClick={() => void loadPlanDetail(planDetailKind, planDetailMeta.page - 1, planDetailAction)}>Anterior</button><span>Página {planDetailMeta.page} de {planDetailMeta.totalPages}</span><button type="button" disabled={planDetailMeta.page >= planDetailMeta.totalPages} onClick={() => void loadPlanDetail(planDetailKind, planDetailMeta.page + 1, planDetailAction)}>Siguiente</button></div>}</>}</div>} {(planDetailKind || planDetailAction) && <div className="import-plan-detail"><div className="table-summary"><strong>{planDetailKind ? ENTITY_LABELS[planDetailKind] : planDetailAction ? PLAN_ACTION_LABELS[planDetailAction] : 'Detalle del plan'}</strong><span>{planDetailMeta.total.toLocaleString('es-AR')} elementos únicos</span></div>{planDetailLoading ? <LoadingBlock label="Cargando detalle del plan…" /> : <><div className="table-scroll"><SortableTable><thead><tr><th>Decisión</th><th>Entidad / relación</th><th>Qué hará el sistema</th><th>Filas fuente</th></tr></thead><tbody>{planDetailItems.map((item) => { const objectId = matchedObjectId(item); return <tr key={item.id}><td><span className={`status-badge ${item.action === 'REVIEW' ? 'observed' : item.action === 'CREATE' ? 'active' : item.action === 'MATCH' ? 'pending' : 'inactive'}`}>{PLAN_ACTION_LABELS[item.action]}</span></td><td><strong>{item.displayName}</strong><small className="cell-subtle">{ENTITY_LABELS[item.entityKind]}</small>{item.entityKind === 'DEPARTMENT' && <small className="cell-subtle">Código {String(item.payload.departmentCode ?? '—')}</small>}{item.entityKind === 'LEGAL_RIGHT' && <small className="cell-subtle">{String(item.payload.rightType ?? '—')} · estado inicial PENDING</small>}</td><td>{item.action === 'MATCH' ? <div className="import-match-explanation"><strong>Se reutiliza un registro del inventario</strong><span>{MATCH_CRITERIA[item.entityKind]}</span>{objectId && <code>ID {objectId.slice(0, 8)}</code>}</div> : item.action === 'CREATE' ? <div className="import-match-explanation"><strong>Se creará un registro nuevo</strong><span>No se encontró una coincidencia exacta y segura con las reglas de este tipo.</span></div> : item.action === 'REVIEW' ? <div className="row-issue-list">{item.reviewCodes.slice(0, 3).map((code) => <span key={code}>{reviewGuidance(code).title}</span>)}</div> : <span className="cell-subtle">No se incorporará al inventario.</span>}</td><td>{sourceRowsLabel(item.sourceRowNumbers)}</td></tr>; })}</tbody></SortableTable></div>{planDetailMeta.totalPages > 1 && <div className="pagination"><button type="button" disabled={planDetailMeta.page <= 1} onClick={() => void loadPlanDetail(planDetailKind, planDetailMeta.page - 1, planDetailAction)}>Anterior</button><span>Página {planDetailMeta.page} de {planDetailMeta.totalPages}</span><button type="button" disabled={planDetailMeta.page >= planDetailMeta.totalPages} onClick={() => void loadPlanDetail(planDetailKind, planDetailMeta.page + 1, planDetailAction)}>Siguiente</button></div>}</>}</div>}
</div>} </div>}
{plan.status === 'REVIEW_REQUIRED' && <div id="import-review-panel" className="import-review-panel"> {plan.status === 'REVIEW_REQUIRED' && <div id="import-review-panel" className="import-review-panel">
@@ -743,7 +744,7 @@ Escribí IMPORTAR para continuar:`);
<div className="table-panel import-rows-table"> <div className="table-panel import-rows-table">
<div className="table-summary"><strong>Vista previa normalizada</strong><span>{rowMeta.total.toLocaleString('es-AR')} filas</span></div> <div className="table-summary"><strong>Vista previa normalizada</strong><span>{rowMeta.total.toLocaleString('es-AR')} filas</span></div>
<div className="table-scroll"><table> <div className="table-scroll"><SortableTable>
{detail.profileCode === 'MENDOZA_YACIMIENTOS_V1' ? <> {detail.profileCode === 'MENDOZA_YACIMIENTOS_V1' ? <>
<thead><tr><th>Fila</th><th>Resultado</th><th>Área</th><th>Yacimiento</th><th>Departamento</th><th>Derecho</th><th>Operadora</th><th>Conciliación</th><th>Observaciones</th></tr></thead> <thead><tr><th>Fila</th><th>Resultado</th><th>Área</th><th>Yacimiento</th><th>Departamento</th><th>Derecho</th><th>Operadora</th><th>Conciliación</th><th>Observaciones</th></tr></thead>
<tbody>{rows.map((row) => { const state = rowStatus(row.status); const rec = record(row.normalizedData.reconciliation); return <tr key={row.id}> <tbody>{rows.map((row) => { const state = rowStatus(row.status); const rec = record(row.normalizedData.reconciliation); return <tr key={row.id}>
@@ -766,7 +767,7 @@ Escribí IMPORTAR para continuar:`);
<td><div className="row-issue-list">{row.issues.length ? row.issues.slice(0, 3).map((issue) => <span key={issue}>{ISSUE_LABELS[issue] ?? issue}</span>) : <span className="ok-text">Sin observaciones</span>}{row.issues.length > 3 && <small>+{row.issues.length - 3}</small>}</div></td> <td><div className="row-issue-list">{row.issues.length ? row.issues.slice(0, 3).map((issue) => <span key={issue}>{ISSUE_LABELS[issue] ?? issue}</span>) : <span className="ok-text">Sin observaciones</span>}{row.issues.length > 3 && <small>+{row.issues.length - 3}</small>}</div></td>
</tr>; })}</tbody> </tr>; })}</tbody>
</>} </>}
</table></div> </SortableTable></div>
<div className="pagination"><button className="button secondary" disabled={rowMeta.page <= 1 || detailLoading} onClick={() => void loadDetail(detail.id, rowMeta.page - 1)}>Anterior</button><span>Página {rowMeta.page} de {Math.max(rowMeta.totalPages, 1)}</span><button className="button secondary" disabled={rowMeta.page >= rowMeta.totalPages || detailLoading} onClick={() => void loadDetail(detail.id, rowMeta.page + 1)}>Siguiente</button></div> <div className="pagination"><button className="button secondary" disabled={rowMeta.page <= 1 || detailLoading} onClick={() => void loadDetail(detail.id, rowMeta.page - 1)}>Anterior</button><span>Página {rowMeta.page} de {Math.max(rowMeta.totalPages, 1)}</span><button className="button secondary" disabled={rowMeta.page >= rowMeta.totalPages || detailLoading} onClick={() => void loadDetail(detail.id, rowMeta.page + 1)}>Siguiente</button></div>
</div> </div>
</>} </>}
+3 -2
View File
@@ -1,3 +1,4 @@
import { SortableTable } from '../components/SortableTable';
import { SearchableSelect } from '../components/SearchableSelect'; import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
@@ -185,7 +186,7 @@ export function AssetsPage() {
? <EmptyState title="Todavía no hay registros" text={selectedPresentation ? `No hay registros cargados en ${selectedPresentation.title}.` : 'La base está lista para comenzar la carga manual desde Departamento.'} /> ? <EmptyState title="Todavía no hay registros" text={selectedPresentation ? `No hay registros cargados en ${selectedPresentation.title}.` : 'La base está lista para comenzar la carga manual desde Departamento.'} />
: <div className="table-panel compact-assets-table"> : <div className="table-panel compact-assets-table">
<div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div> <div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div>
<div className="table-scroll"><table><thead><tr><th>Registro</th><th>Sección</th><th>Ubicación / Operadora</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody> <div className="table-scroll"><SortableTable><thead><tr><th>Registro</th><th>Sección</th><th>Ubicación / Operadora</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody>
{assets.map((asset) => <tr key={asset.id}> {assets.map((asset) => <tr key={asset.id}>
<td><div className="asset-cell"><span className="asset-symbol"><Icon name="layers" size={16} /></span><div><Link to={`/inventarios/${asset.id}`} className="table-primary">{asset.name}</Link><small>{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}</small></div></div></td> <td><div className="asset-cell"><span className="asset-symbol"><Icon name="layers" size={16} /></span><div><Link to={`/inventarios/${asset.id}`} className="table-primary">{asset.name}</Link><small>{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}</small></div></div></td>
<td><span className="tag">{asset.type.name}</span></td> <td><span className="tag">{asset.type.name}</span></td>
@@ -194,7 +195,7 @@ export function AssetsPage() {
<td>{formatDate(asset.updatedAt)}</td> <td>{formatDate(asset.updatedAt)}</td>
<td className="action-cell"><Link className="icon-button" to={`/inventarios/${asset.id}`} aria-label={`Abrir ${asset.name}`}><Icon name="chevron" /></Link></td> <td className="action-cell"><Link className="icon-button" to={`/inventarios/${asset.id}`} aria-label={`Abrir ${asset.name}`}><Icon name="chevron" /></Link></td>
</tr>)} </tr>)}
</tbody></table></div> </tbody></SortableTable></div>
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div> <div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
</div>} </div>}
</section>; </section>;
+2 -1
View File
@@ -1,3 +1,4 @@
import { SortableTable } from '../components/SortableTable';
import { SearchableSelect } from '../components/SearchableSelect'; import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
@@ -62,7 +63,7 @@ export function AuditPage() {
<div className="filter-actions"><button className="button text" type="button" onClick={clear}>Limpiar</button><button className="button primary">Aplicar filtros</button></div> <div className="filter-actions"><button className="button text" type="button" onClick={clear}>Limpiar</button><button className="button primary">Aplicar filtros</button></div>
</form> </form>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Consultando auditoría…" /> : events.length === 0 ? <EmptyState title="Sin eventos" text="No hay registros para los filtros seleccionados." /> : <div className="table-panel audit-table"><div className="table-scroll"><table><thead><tr><th>Fecha y hora</th><th>Usuario</th><th>Acción</th><th>Entidad</th><th>Origen</th><th>Request ID</th><th /></tr></thead><tbody>{events.map((event) => <tr key={event.id}><td>{formatDate(event.occurredAt)}</td><td><strong>{event.actorUsername ?? 'Sistema'}</strong><small className="cell-subtext">{event.ip ?? 'IP no registrada'}</small></td><td><span className={`event-badge ${event.action.includes('FAILED') || event.action.includes('REUSE') ? 'warning' : ''}`}>{actionLabel(event.action)}</span><small className="cell-subtext code-text">{event.action}</small></td><td>{event.entityType ?? '—'}<small className="cell-subtext code-text">{event.entityId ?? ''}</small></td><td><span className="tag">{event.source}</span></td><td><code>{event.requestId ? `${event.requestId.slice(0, 12)}` : '—'}</code></td><td><button className="icon-button" onClick={() => openDetail(event.id)} aria-label="Ver detalle"><Icon name="chevron" /></button></td></tr>)}</tbody></table></div><div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>Página {page} de {Math.max(meta.totalPages, 1)}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>} {loading ? <LoadingBlock label="Consultando auditoría…" /> : events.length === 0 ? <EmptyState title="Sin eventos" text="No hay registros para los filtros seleccionados." /> : <div className="table-panel audit-table"><div className="table-scroll"><SortableTable><thead><tr><th>Fecha y hora</th><th>Usuario</th><th>Acción</th><th>Entidad</th><th>Origen</th><th>Request ID</th><th /></tr></thead><tbody>{events.map((event) => <tr key={event.id}><td>{formatDate(event.occurredAt)}</td><td><strong>{event.actorUsername ?? 'Sistema'}</strong><small className="cell-subtext">{event.ip ?? 'IP no registrada'}</small></td><td><span className={`event-badge ${event.action.includes('FAILED') || event.action.includes('REUSE') ? 'warning' : ''}`}>{actionLabel(event.action)}</span><small className="cell-subtext code-text">{event.action}</small></td><td>{event.entityType ?? '—'}<small className="cell-subtext code-text">{event.entityId ?? ''}</small></td><td><span className="tag">{event.source}</span></td><td><code>{event.requestId ? `${event.requestId.slice(0, 12)}` : '—'}</code></td><td><button className="icon-button" onClick={() => openDetail(event.id)} aria-label="Ver detalle"><Icon name="chevron" /></button></td></tr>)}</tbody></SortableTable></div><div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>Página {page} de {Math.max(meta.totalPages, 1)}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
{(detailLoading || detail) && <div className="modal-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget && !detailLoading) setDetail(null); }}><aside className="detail-drawer" role="dialog" aria-modal="true" aria-label="Detalle de auditoría">{detailLoading ? <LoadingBlock label="Cargando detalle…" /> : detail && <><div className="drawer-heading"><div><span className="eyebrow">EVENTO DE AUDITORÍA</span><h2>{actionLabel(detail.action)}</h2></div><button className="icon-button" onClick={() => setDetail(null)} aria-label="Cerrar">×</button></div><dl className="detail-list"><div><dt>Fecha</dt><dd>{formatDate(detail.occurredAt)}</dd></div><div><dt>Usuario</dt><dd>{detail.actorUsername ?? 'Sistema'}</dd></div><div><dt>Origen / IP</dt><dd>{detail.source} · {detail.ip ?? '—'}</dd></div><div><dt>Entidad</dt><dd>{detail.entityType ?? '—'} {detail.entityId ? `· ${detail.entityId}` : ''}</dd></div><div><dt>Request ID</dt><dd><code>{detail.requestId ?? '—'}</code></dd></div><div><dt>User agent</dt><dd>{detail.userAgent ?? '—'}</dd></div></dl><JsonDetail title="Datos anteriores" value={detail.beforeData} /><JsonDetail title="Datos posteriores" value={detail.afterData} /><JsonDetail title="Metadatos" value={detail.metadata} /></>}</aside></div>} {(detailLoading || detail) && <div className="modal-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget && !detailLoading) setDetail(null); }}><aside className="detail-drawer" role="dialog" aria-modal="true" aria-label="Detalle de auditoría">{detailLoading ? <LoadingBlock label="Cargando detalle…" /> : detail && <><div className="drawer-heading"><div><span className="eyebrow">EVENTO DE AUDITORÍA</span><h2>{actionLabel(detail.action)}</h2></div><button className="icon-button" onClick={() => setDetail(null)} aria-label="Cerrar">×</button></div><dl className="detail-list"><div><dt>Fecha</dt><dd>{formatDate(detail.occurredAt)}</dd></div><div><dt>Usuario</dt><dd>{detail.actorUsername ?? 'Sistema'}</dd></div><div><dt>Origen / IP</dt><dd>{detail.source} · {detail.ip ?? '—'}</dd></div><div><dt>Entidad</dt><dd>{detail.entityType ?? '—'} {detail.entityId ? `· ${detail.entityId}` : ''}</dd></div><div><dt>Request ID</dt><dd><code>{detail.requestId ?? '—'}</code></dd></div><div><dt>User agent</dt><dd>{detail.userAgent ?? '—'}</dd></div></dl><JsonDetail title="Datos anteriores" value={detail.beforeData} /><JsonDetail title="Datos posteriores" value={detail.afterData} /><JsonDetail title="Metadatos" value={detail.metadata} /></>}</aside></div>}
</section>; </section>;
+3 -2
View File
@@ -1,3 +1,4 @@
import { SortableTable } from '../components/SortableTable';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router'; import { Link, useSearchParams } from 'react-router';
@@ -405,7 +406,7 @@ export function CompanyInventoryPage({ initialAsset }: { initialAsset: AssetDeta
<div className="company-card-heading company-doc-heading"><div><span className="eyebrow">DOCUMENTACIÓN EMITIDA</span><h2>Actas e informes</h2><p>Historial documental de esta empresa. Sólo se muestran Actas ya emitidas y sus Informes asociados.</p></div><div className="company-doc-counts"><span>{acts.length} actas</span><span>{reports.length} informes</span></div></div> <div className="company-card-heading company-doc-heading"><div><span className="eyebrow">DOCUMENTACIÓN EMITIDA</span><h2>Actas e informes</h2><p>Historial documental de esta empresa. Sólo se muestran Actas ya emitidas y sus Informes asociados.</p></div><div className="company-doc-counts"><span>{acts.length} actas</span><span>{reports.length} informes</span></div></div>
<label className="company-document-search"><Icon name="search" /><input value={documentSearch} onChange={(event) => setDocumentSearch(event.target.value)} placeholder="Buscar por acta, informe, inspección, área o GEDO" /></label> <label className="company-document-search"><Icon name="search" /><input value={documentSearch} onChange={(event) => setDocumentSearch(event.target.value)} placeholder="Buscar por acta, informe, inspección, área o GEDO" /></label>
{documentsError && <Alert>{documentsError}</Alert>} {documentsError && <Alert>{documentsError}</Alert>}
{documentsLoading ? <LoadingBlock label="Cargando documentación emitida…" /> : filteredActs.length === 0 ? <EmptyState title="Sin documentación emitida" text="Todavía no hay Actas selladas para esta empresa." /> : <div className="table-scroll company-doc-table"><table><thead><tr><th>Fecha</th><th>Área</th><th>Acta</th><th>Inspección</th><th>Informe</th><th>Oficialización</th><th /></tr></thead><tbody>{filteredActs.map((act) => { {documentsLoading ? <LoadingBlock label="Cargando documentación emitida…" /> : filteredActs.length === 0 ? <EmptyState title="Sin documentación emitida" text="Todavía no hay Actas selladas para esta empresa." /> : <div className="table-scroll company-doc-table"><SortableTable><thead><tr><th>Fecha</th><th>Área</th><th>Acta</th><th>Inspección</th><th>Informe</th><th>Oficialización</th><th /></tr></thead><tbody>{filteredActs.map((act) => {
const report = reportByAct.get(act.id); const report = reportByAct.get(act.id);
return <tr key={act.id}> return <tr key={act.id}>
<td>{formatDate(act.occurredAt)}</td> <td>{formatDate(act.occurredAt)}</td>
@@ -416,7 +417,7 @@ export function CompanyInventoryPage({ initialAsset }: { initialAsset: AssetDeta
<td>{report ? <span className={`status-badge ${reportStatusClass(report)}`}>{reportStatusLabel(report)}</span> : <span className="muted"></span>}</td> <td>{report ? <span className={`status-badge ${reportStatusClass(report)}`}>{reportStatusLabel(report)}</span> : <span className="muted"></span>}</td>
<td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><Icon name="chevron" /></Link></td> <td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><Icon name="chevron" /></Link></td>
</tr>; </tr>;
})}</tbody></table></div>} })}</tbody></SortableTable></div>}
</article> </article>
</div>} </div>}
+1
View File
@@ -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>
+2 -1
View File
@@ -1,3 +1,4 @@
import { SortableTable } from '../components/SortableTable';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
@@ -189,7 +190,7 @@ export function DocumentDeliveryPage() {
<article className="panel"> <article className="panel">
<div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD</span><h2>Últimas entregas</h2><p className="section-copy">Actas PDF a Empresa/Oficina/Inspector e INF Word editable al Inspector responsable.</p></div><span className="count-pill">{items.length}</span></div> <div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD</span><h2>Últimas entregas</h2><p className="section-copy">Actas PDF a Empresa/Oficina/Inspector e INF Word editable al Inspector responsable.</p></div><span className="count-pill">{items.length}</span></div>
{items.length === 0 ? <div className="inline-empty">Todavía no existen entregas documentales.</div> : <div className="table-wrap"><table><thead><tr><th>Documento</th><th>Destino</th><th>Estado</th><th>Intentos</th><th /></tr></thead><tbody>{items.map((item) => <tr key={item.id}><td><strong>{item.documentKind === 'ACT_PDF' ? item.actCode : item.reportCode}</strong><small className="block-muted">{item.documentKind === 'ACT_PDF' ? 'Acta PDF inmutable' : 'INF Word editable'}</small></td><td>{recipientLabel(item)}<small className="block-muted">{item.recipientEmail ?? 'Sin email configurado'}</small></td><td><span className={`status-badge ${item.status === 'SENT' ? 'active' : item.status === 'FAILED' ? 'inactive' : 'pending'}`}>{statusLabel[item.status]}</span>{item.lastError && <small className="block-muted">{item.lastError}</small>}</td><td>{item.attempts}</td><td>{canManage && item.status !== 'SENT' && <button className="button text" type="button" disabled={saving} onClick={async () => { setSaving(true); try { await retryDocumentDeliveryF4(item.id); await load(); } catch (requestError) { setError(errorMessage(requestError)); } finally { setSaving(false); } }}>Reintentar</button>}</td></tr>)}</tbody></table></div>} {items.length === 0 ? <div className="inline-empty">Todavía no existen entregas documentales.</div> : <div className="table-wrap"><SortableTable><thead><tr><th>Documento</th><th>Destino</th><th>Estado</th><th>Intentos</th><th /></tr></thead><tbody>{items.map((item) => <tr key={item.id}><td><strong>{item.documentKind === 'ACT_PDF' ? item.actCode : item.reportCode}</strong><small className="block-muted">{item.documentKind === 'ACT_PDF' ? 'Acta PDF inmutable' : 'INF Word editable'}</small></td><td>{recipientLabel(item)}<small className="block-muted">{item.recipientEmail ?? 'Sin email configurado'}</small></td><td><span className={`status-badge ${item.status === 'SENT' ? 'active' : item.status === 'FAILED' ? 'inactive' : 'pending'}`}>{statusLabel[item.status]}</span>{item.lastError && <small className="block-muted">{item.lastError}</small>}</td><td>{item.attempts}</td><td>{canManage && item.status !== 'SENT' && <button className="button text" type="button" disabled={saving} onClick={async () => { setSaving(true); try { await retryDocumentDeliveryF4(item.id); await load(); } catch (requestError) { setError(errorMessage(requestError)); } finally { setSaving(false); } }}>Reintentar</button>}</td></tr>)}</tbody></SortableTable></div>}
</article> </article>
</section>; </section>;
} }
+3 -2
View File
@@ -1,3 +1,4 @@
import { SortableTable } from '../components/SortableTable';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router'; import { Link, useSearchParams } from 'react-router';
import { SearchableSelect } from '../components/SearchableSelect'; import { SearchableSelect } from '../components/SearchableSelect';
@@ -244,7 +245,7 @@ export function FieldBriefingsPage() {
</div> </div>
<div className="table-scroll"> <div className="table-scroll">
<table> <SortableTable>
<thead><tr><th>Qué hacer</th><th>Hallazgo</th><th>Inventario</th><th>Control</th></tr></thead> <thead><tr><th>Qué hacer</th><th>Hallazgo</th><th>Inventario</th><th>Control</th></tr></thead>
<tbody>{act.findings.map((finding) => { <tbody>{act.findings.map((finding) => {
const action = findingAction(finding, act, briefing.plannedOn); const action = findingAction(finding, act, briefing.plannedOn);
@@ -264,7 +265,7 @@ export function FieldBriefingsPage() {
<td>{finding.nextControlOn ? formatDateOnly(finding.nextControlOn) : 'Sin fecha'}</td> <td>{finding.nextControlOn ? formatDateOnly(finding.nextControlOn) : 'Sin fecha'}</td>
</tr>; </tr>;
})}</tbody> })}</tbody>
</table> </SortableTable>
</div> </div>
</article>)} </article>)}
</>} </>}
+4 -3
View File
@@ -1,3 +1,4 @@
import { SortableTable } from '../components/SortableTable';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
@@ -126,7 +127,7 @@ export function FieldDiscoveriesPage() {
{loading ? <LoadingBlock label="Cargando altas de campo…" /> : items.length === 0 ? <EmptyState title="No hay altas en esta bandeja" text="Cuando un inspector registre un elemento nuevo durante una visita aparecerá aquí." /> : <div className="table-panel"> {loading ? <LoadingBlock label="Cargando altas de campo…" /> : items.length === 0 ? <EmptyState title="No hay altas en esta bandeja" text="Cuando un inspector registre un elemento nuevo durante una visita aparecerá aquí." /> : <div className="table-panel">
<div className="table-summary"><strong>{meta.total} alta{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div> <div className="table-summary"><strong>{meta.total} alta{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div>
<div className="table-scroll"><table><thead><tr><th>Elemento</th><th>Inspección</th><th>Inspector</th><th>Estado</th><th>Acciones</th></tr></thead><tbody> <div className="table-scroll"><SortableTable><thead><tr><th>Elemento</th><th>Inspección</th><th>Inspector</th><th>Estado</th><th>Acciones</th></tr></thead><tbody>
{items.map((item) => <tr key={item.id}> {items.map((item) => <tr key={item.id}>
<td><Link className="table-primary" to={`/inventarios/${item.asset.id}`}>{item.asset.name}</Link><small className="cell-subtext">{item.asset.code} · {item.asset.typeName}{item.asset.commonName ? ` · ${item.asset.commonName}` : ''}</small></td> <td><Link className="table-primary" to={`/inventarios/${item.asset.id}`}>{item.asset.name}</Link><small className="cell-subtext">{item.asset.code} · {item.asset.typeName}{item.asset.commonName ? ` · ${item.asset.commonName}` : ''}</small></td>
<td><Link to={`/inspecciones/${item.visit.id}`}>{item.visit.code}</Link><small className="cell-subtext">{formatDate(item.observedAt)}</small></td> <td><Link to={`/inspecciones/${item.visit.id}`}>{item.visit.code}</Link><small className="cell-subtext">{formatDate(item.observedAt)}</small></td>
@@ -141,14 +142,14 @@ export function FieldDiscoveriesPage() {
</>} </>}
</td> </td>
</tr>)} </tr>)}
</tbody></table></div> </tbody></SortableTable></div>
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div> <div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
</div>} </div>}
{matchFor && <div className="panel field-discovery-match-panel"> {matchFor && <div className="panel field-discovery-match-panel">
<div className="panel-heading"><div><span className="eyebrow">CONCILIAR</span><h2>Buscar registro existente</h2><p className="section-copy">Buscá dentro de la misma Área física. El alta de campo se archivará, pero su historial no se elimina.</p></div><button className="button text" onClick={() => setMatchFor(null)}>Cerrar</button></div> <div className="panel-heading"><div><span className="eyebrow">CONCILIAR</span><h2>Buscar registro existente</h2><p className="section-copy">Buscá dentro de la misma Área física. El alta de campo se archivará, pero su historial no se elimina.</p></div><button className="button text" onClick={() => setMatchFor(null)}>Cerrar</button></div>
<form className="asset-center-search" onSubmit={searchMatches}><Icon name="search" /><input value={matchSearch} onChange={(event) => setMatchSearch(event.target.value)} placeholder="Nombre, sobrenombre o código existente…" /><button className="button primary" disabled={matchLoading}>{matchLoading ? 'Buscando…' : 'Buscar'}</button></form> <form className="asset-center-search" onSubmit={searchMatches}><Icon name="search" /><input value={matchSearch} onChange={(event) => setMatchSearch(event.target.value)} placeholder="Nombre, sobrenombre o código existente…" /><button className="button primary" disabled={matchLoading}>{matchLoading ? 'Buscando…' : 'Buscar'}</button></form>
{candidates.length > 0 && <div className="table-scroll"><table><thead><tr><th>Registro existente</th><th>Tipo</th><th /></tr></thead><tbody>{candidates.map((candidate) => <tr key={candidate.id}><td><strong>{candidate.name}</strong><small className="cell-subtext">{candidate.code}{candidate.commonName ? ` · ${candidate.commonName}` : ''}</small></td><td>{candidate.type.name}</td><td className="action-cell"><button className="button primary compact" onClick={() => chooseMatch(candidate)}>Usar esta coincidencia</button></td></tr>)}</tbody></table></div>} {candidates.length > 0 && <div className="table-scroll"><SortableTable><thead><tr><th>Registro existente</th><th>Tipo</th><th /></tr></thead><tbody>{candidates.map((candidate) => <tr key={candidate.id}><td><strong>{candidate.name}</strong><small className="cell-subtext">{candidate.code}{candidate.commonName ? ` · ${candidate.commonName}` : ''}</small></td><td>{candidate.type.name}</td><td className="action-cell"><button className="button primary compact" onClick={() => chooseMatch(candidate)}>Usar esta coincidencia</button></td></tr>)}</tbody></SortableTable></div>}
</div>} </div>}
</section>; </section>;
} }
+3 -2
View File
@@ -1,3 +1,4 @@
import { SortableTable } from '../components/SortableTable';
import { SearchableSelect } from '../components/SearchableSelect'; import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
@@ -160,7 +161,7 @@ export function FindingsPage() {
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Cargando hallazgos…" /> : items.length === 0 ? <EmptyState title="Sin hallazgos en esta vista" text="No hay tareas técnicas pendientes para el filtro seleccionado." /> : <div className="table-panel findings-worklist"> {loading ? <LoadingBlock label="Cargando hallazgos…" /> : items.length === 0 ? <EmptyState title="Sin hallazgos en esta vista" text="No hay tareas técnicas pendientes para el filtro seleccionado." /> : <div className="table-panel findings-worklist">
<div className="table-summary"><strong>{meta.total} hallazgo{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div> <div className="table-summary"><strong>{meta.total} hallazgo{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div>
<div className="table-scroll"><table><thead><tr><th>Hallazgo</th><th>Empresa / área</th><th>Elemento</th><th>Situación</th><th>Fecha de control</th><th>Descripción</th><th /></tr></thead><tbody>{items.map((finding) => { <div className="table-scroll"><SortableTable><thead><tr><th>Hallazgo</th><th>Empresa / área</th><th>Elemento</th><th>Situación</th><th>Fecha de control</th><th>Descripción</th><th /></tr></thead><tbody>{items.map((finding) => {
const attention = attentionLabel(finding); const attention = attentionLabel(finding);
const deadline = deadlineText(finding); const deadline = deadlineText(finding);
return <tr key={finding.id}> return <tr key={finding.id}>
@@ -172,7 +173,7 @@ export function FindingsPage() {
<td><small>{finding.description}</small></td> <td><small>{finding.description}</small></td>
<td className="action-cell"><Link className="icon-button" to={`/hallazgos/${finding.id}`} aria-label={`Abrir ${finding.code}`}><Icon name="chevron" /></Link></td> <td className="action-cell"><Link className="icon-button" to={`/hallazgos/${finding.id}`} aria-label={`Abrir ${finding.code}`}><Icon name="chevron" /></Link></td>
</tr>; </tr>;
})}</tbody></table></div> })}</tbody></SortableTable></div>
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div> <div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
</div>} </div>}
</section>; </section>;
+2 -1
View File
@@ -1,3 +1,4 @@
import { SortableTable } from '../components/SortableTable';
import { SearchableSelect } from '../components/SearchableSelect'; import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
@@ -123,7 +124,7 @@ export function HistoryPage() {
</form> </form>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Consultando versiones…" /> : versions.length === 0 ? <EmptyState title="Sin versiones" text="No hay versiones históricas para los filtros seleccionados." /> : <div className="table-panel history-table"><div className="table-summary"><strong>{meta.total} versiones registradas</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><table><thead><tr><th>Fecha y hora</th><th>Registro</th><th>Versión</th><th>Cambio</th><th>Campos</th><th>Usuario</th><th>Estado</th><th /></tr></thead><tbody>{versions.map((version) => <tr key={version.id}><td>{formatDate(version.occurredAt)}</td><td><Link className="history-asset-link" to={`/inventarios/${version.assetId}`}><strong>{version.assetName}</strong><small>{version.assetCode} · {version.typeName}</small></Link></td><td><span className={`version-badge ${version.isCurrent ? 'current' : ''}`}>v{version.versionNumber}{version.isCurrent ? ' · actual' : ''}</span></td><td><strong>{assetVersionChangeLabel(version.changeType)}</strong><small className="cell-subtext">{version.source}</small></td><td><div className="tag-list">{version.changedFields.slice(0, 3).map((field) => <span className="tag" key={field}>{assetVersionFieldLabel(field)}</span>)}</div></td><td>{version.actorUsername ?? 'Sistema'}</td><td>{assetStatusLabel(version.informationStatus)}</td><td><button type="button" className="icon-button" onClick={() => open(version)} aria-label={`Ver versión ${version.versionNumber}`}><Icon name="chevron" /></button></td></tr>)}</tbody></table></div><div className="pagination"><button type="button" className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button type="button" className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>} {loading ? <LoadingBlock label="Consultando versiones…" /> : versions.length === 0 ? <EmptyState title="Sin versiones" text="No hay versiones históricas para los filtros seleccionados." /> : <div className="table-panel history-table"><div className="table-summary"><strong>{meta.total} versiones registradas</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><SortableTable><thead><tr><th>Fecha y hora</th><th>Registro</th><th>Versión</th><th>Cambio</th><th>Campos</th><th>Usuario</th><th>Estado</th><th /></tr></thead><tbody>{versions.map((version) => <tr key={version.id}><td>{formatDate(version.occurredAt)}</td><td><Link className="history-asset-link" to={`/inventarios/${version.assetId}`}><strong>{version.assetName}</strong><small>{version.assetCode} · {version.typeName}</small></Link></td><td><span className={`version-badge ${version.isCurrent ? 'current' : ''}`}>v{version.versionNumber}{version.isCurrent ? ' · actual' : ''}</span></td><td><strong>{assetVersionChangeLabel(version.changeType)}</strong><small className="cell-subtext">{version.source}</small></td><td><div className="tag-list">{version.changedFields.slice(0, 3).map((field) => <span className="tag" key={field}>{assetVersionFieldLabel(field)}</span>)}</div></td><td>{version.actorUsername ?? 'Sistema'}</td><td>{assetStatusLabel(version.informationStatus)}</td><td><button type="button" className="icon-button" onClick={() => open(version)} aria-label={`Ver versión ${version.versionNumber}`}><Icon name="chevron" /></button></td></tr>)}</tbody></SortableTable></div><div className="pagination"><button type="button" className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button type="button" className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
{(detailLoading || detail) && <AssetVersionDrawer detail={detail} loading={detailLoading} onClose={() => setDetail(null)} />} {(detailLoading || detail) && <AssetVersionDrawer detail={detail} loading={detailLoading} onClose={() => setDetail(null)} />}
</section>; </section>;
+14 -1
View File
@@ -80,10 +80,23 @@ export function InspectionActEditorPage() {
<div className="act-document-primary-copy"><span className="asset-symbol"><Icon name="clipboard" /></span><div><span className="eyebrow">DOCUMENTO DEL ACTA</span><h2>{isSealed ? 'Acta consolidada disponible' : 'Acta en preparación'}</h2><p>{isSealed ? 'Abrí o descargá el Acta firmada, con sus Hallazgos y constancias de integridad.' : 'El documento definitivo se genera al firmar y cerrar el Acta.'}</p></div></div> <div className="act-document-primary-copy"><span className="asset-symbol"><Icon name="clipboard" /></span><div><span className="eyebrow">DOCUMENTO DEL ACTA</span><h2>{isSealed ? 'Acta consolidada disponible' : 'Acta en preparación'}</h2><p>{isSealed ? 'Abrí o descargá el Acta firmada, con sus Hallazgos y constancias de integridad.' : 'El documento definitivo se genera al firmar y cerrar el Acta.'}</p></div></div>
<div className="act-primary-actions">{isSealed ? <><button className="button primary" type="button" disabled={pdfBusy} onClick={() => void openActPdf(false)}>{pdfBusy ? 'Preparando…' : 'Abrir PDF del Acta'}</button><button className="button secondary" type="button" disabled={pdfBusy} onClick={() => void openActPdf(true)}>Descargar PDF</button></> : <span className="status-badge pending">{inspectionActStatusLabel(act.status)}</span>}</div> <div className="act-primary-actions">{isSealed ? <><button className="button primary" type="button" disabled={pdfBusy} onClick={() => void openActPdf(false)}>{pdfBusy ? 'Preparando…' : 'Abrir PDF del Acta'}</button><button className="button secondary" type="button" disabled={pdfBusy} onClick={() => void openActPdf(true)}>Descargar PDF</button></> : <span className="status-badge pending">{inspectionActStatusLabel(act.status)}</span>}</div>
</section>} </section>}
{act?.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Informe relacionado: <Link to={`/informes/${act.report.id}`}>{act.report.code}</Link>.</strong> {act.report.pdfStatus === 'READY' ? ' Disponible.' : ' En preparación.'}</p></div>} {act?.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Informe relacionado: <Link to={`/informes/${act.report.id}`}>{act.report.code}</Link>.</strong> {act.report.status === 'OFFICIALIZED' ? ` Oficializado en GEDO${act.report.gedoIfIdentifier ? ` · ${act.report.gedoIfIdentifier}` : ''}.` : act.report.status === 'FROZEN' ? ' Informe consolidado.' : act.report.wordStatus === 'READY' ? ' INF listo para enviar a GEDO.' : ' En preparación.'}</p></div>}
{isSealed && act && !act.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>INF pendiente de emisión.</strong> El Acta ya está sellada y disponible como documento fuente.</p></div>} {isSealed && act && !act.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>INF pendiente de emisión.</strong> El Acta ya está sellada y disponible como documento fuente.</p></div>}
{act?.status === 'CANCELLED' && <Alert>Cancelada: {act.cancellationReason}</Alert>} {act?.status === 'CANCELLED' && <Alert>Cancelada: {act.cancellationReason}</Alert>}
{act && <section className="panel inspection-act-context">
<div className="panel-heading"><div><span className="eyebrow">CONTEXTO DEL ACTA</span><h2>Ubicación territorial y operativa</h2><p className="section-copy">El Acta corresponde a un Yacimiento y los Hallazgos se ubican dentro de sus Instalaciones y Subinstalaciones.</p></div></div>
<div className="responsible-summary">
<div><small>Departamento</small><strong>{act.context.department?.name ?? 'Sin definir'}</strong>{act.context.department && <span>{act.context.department.code}</span>}</div>
<div><small>Área</small><strong>{act.context.area?.name ?? 'Sin definir'}</strong>{act.context.area && <span>{act.context.area.code}</span>}</div>
<div><small>Yacimiento</small><strong>{act.context.yacimiento?.name ?? 'No definido en la Inspección histórica'}</strong>{act.context.yacimiento && <span>{act.context.yacimiento.code}</span>}</div>
<div><small>Empresa / Operadora</small><strong>{act.context.company?.name ?? 'Sin definir'}</strong>{act.context.company && <span>{act.context.company.code}</span>}</div>
<div><small>Instalaciones con Hallazgos</small><strong>{act.context.installations.length ? act.context.installations.map((item) => item.name).join(' · ') : 'Ninguna'}</strong></div>
<div><small>Subinstalaciones con Hallazgos</small><strong>{act.context.subinstallations.length ? act.context.subinstallations.map((item) => item.name).join(' · ') : 'Ninguna'}</strong></div>
</div>
{act.context.legacyAreaScope && !act.context.yacimiento && <Alert type="info">Esta Acta pertenece a una Inspección histórica creada antes de exigir Yacimiento como alcance. El Área se conserva como fue registrada; no se la presenta como Yacimiento.</Alert>}
</section>}
{act && <section className="panel inspection-act-form"> {act && <section className="panel inspection-act-form">
<div className="responsible-summary"> <div className="responsible-summary">
<div><small>Fecha de inspección</small><strong>{formatDate(act.occurredAt)}</strong></div> <div><small>Fecha de inspección</small><strong>{formatDate(act.occurredAt)}</strong></div>
@@ -1,3 +1,4 @@
import { SortableTable } from '../components/SortableTable';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router'; import { Link, useNavigate, useParams } from 'react-router';
@@ -357,14 +358,14 @@ export function InspectionVisitEditorF4Page() {
<div className="panel-heading"><div><span className="eyebrow">CHECKLIST TÉCNICO</span><h2>Antecedentes y controles</h2><p className="section-copy">Se construye para el Área, Yacimiento y Operadora fijados en la Inspección, tomando la fecha planificada. Las respuestas administrativas de empresa no modifican este checklist.</p></div>{canManage && planningEditable && <button type="button" className="button secondary" onClick={() => void regenerateChecklist()} disabled={busy}>Regenerar checklist</button>}</div> <div className="panel-heading"><div><span className="eyebrow">CHECKLIST TÉCNICO</span><h2>Antecedentes y controles</h2><p className="section-copy">Se construye para el Área, Yacimiento y Operadora fijados en la Inspección, tomando la fecha planificada. Las respuestas administrativas de empresa no modifican este checklist.</p></div>{canManage && planningEditable && <button type="button" className="button secondary" onClick={() => void regenerateChecklist()} disabled={busy}>Regenerar checklist</button>}</div>
{visit.checklist.stale && <Alert>La fecha cambió. Regenerá el checklist antes de planificar.</Alert>} {visit.checklist.stale && <Alert>La fecha cambió. Regenerá el checklist antes de planificar.</Alert>}
<div className="inspection-checklist-metrics"><div><strong>{visit.checklist.verificationOverdue}</strong><span>controles vencidos</span></div><div><strong>{visit.checklist.upcomingControls}</strong><span>próximos 30 días</span></div><div><strong>{visit.checklist.antecedents}</strong><span>antecedentes</span></div><div><strong>{visit.checklist.actionableAssets}</strong><span>registros sugeridos</span></div></div> <div className="inspection-checklist-metrics"><div><strong>{visit.checklist.verificationOverdue}</strong><span>controles vencidos</span></div><div><strong>{visit.checklist.upcomingControls}</strong><span>próximos 30 días</span></div><div><strong>{visit.checklist.antecedents}</strong><span>antecedentes</span></div><div><strong>{visit.checklist.actionableAssets}</strong><span>registros sugeridos</span></div></div>
{visit.checklist.items.length === 0 ? <EmptyState title="Sin antecedentes" text="No hay Hallazgos históricos técnicos para este contexto." /> : <div className="table-scroll"><table><thead><tr><th>Tipo</th><th>Hallazgo</th><th>Inventario</th><th>Fecha</th><th>Gravedad</th></tr></thead><tbody>{visit.checklist.items.map((item) => <tr key={item.id}><td><span className={`status-badge ${item.itemKind === 'ANTECEDENT' ? '' : 'warning'}`}>{checklistLabel(item.itemKind)}</span></td><td><Link className="history-asset-link" to={`/hallazgos/${item.findingId}`}><strong>{item.findingTitle}</strong><small>{item.findingCode}</small></Link></td><td><Link className="history-asset-link" to={`/inventarios/${item.asset.id}`}><strong>{item.asset.name}</strong><small>{item.asset.code} · {item.asset.typeName}</small></Link></td><td>{formatDateOnly(item.referenceOn)}</td><td>{item.severity ?? '—'}</td></tr>)}</tbody></table></div>} {visit.checklist.items.length === 0 ? <EmptyState title="Sin antecedentes" text="No hay Hallazgos históricos técnicos para este contexto." /> : <div className="table-scroll"><SortableTable><thead><tr><th>Tipo</th><th>Hallazgo</th><th>Inventario</th><th>Fecha</th><th>Gravedad</th></tr></thead><tbody>{visit.checklist.items.map((item) => <tr key={item.id}><td><span className={`status-badge ${item.itemKind === 'ANTECEDENT' ? '' : 'warning'}`}>{checklistLabel(item.itemKind)}</span></td><td><Link className="history-asset-link" to={`/hallazgos/${item.findingId}`}><strong>{item.findingTitle}</strong><small>{item.findingCode}</small></Link></td><td><Link className="history-asset-link" to={`/inventarios/${item.asset.id}`}><strong>{item.asset.name}</strong><small>{item.asset.code} · {item.asset.typeName}</small></Link></td><td>{formatDateOnly(item.referenceOn)}</td><td>{item.severity ?? '—'}</td></tr>)}</tbody></SortableTable></div>}
</article>} </article>}
{visit && visit.verificationFindings.length > 0 && <article className="panel verification-visit-findings"><div className="panel-heading"><div><span className="eyebrow">VERIFICACIÓN</span><h2>Hallazgos a controlar</h2></div><span className="count-pill">{visit.verificationFindings.length}</span></div><div className="dossier-link-list">{visit.verificationFindings.map((finding) => <Link key={finding.id} to={`/hallazgos/${finding.id}`}><div><strong>{finding.title}</strong><small>{finding.code} · {finding.assetName} · objetivo {formatDateOnly(finding.targetControlOn ?? finding.nextControlOn)}</small>{finding.resultNotes && <small>{finding.resultNotes}</small>}</div><span>{finding.outcome === 'RESOLVED' ? 'Solucionado' : finding.outcome === 'NOT_RESOLVED' ? 'No solucionado' : finding.outcome === 'REQUIRES_NEW_DATE' ? 'Reprogramar' : 'Pendiente'}</span><Icon name="chevron" size={16} /></Link>)}</div></article>} {visit && visit.verificationFindings.length > 0 && <article className="panel verification-visit-findings"><div className="panel-heading"><div><span className="eyebrow">VERIFICACIÓN</span><h2>Hallazgos a controlar</h2></div><span className="count-pill">{visit.verificationFindings.length}</span></div><div className="dossier-link-list">{visit.verificationFindings.map((finding) => <Link key={finding.id} to={`/hallazgos/${finding.id}`}><div><strong>{finding.title}</strong><small>{finding.code} · {finding.assetName} · objetivo {formatDateOnly(finding.targetControlOn ?? finding.nextControlOn)}</small>{finding.resultNotes && <small>{finding.resultNotes}</small>}</div><span>{finding.outcome === 'RESOLVED' ? 'Solucionado' : finding.outcome === 'NOT_RESOLVED' ? 'No solucionado' : finding.outcome === 'REQUIRES_NEW_DATE' ? 'Reprogramar' : 'Pendiente'}</span><Icon name="chevron" size={16} /></Link>)}</div></article>}
{visit && planningEditable && canManage && <form className="panel survey-add-target" onSubmit={addPreventiveAsset}><div className="panel-heading"><div><span className="eyebrow">PREVENTIVO</span><h2>Agregar Inventario sin pendiente previo</h2><p className="section-copy">Sólo se muestran Instalaciones y Subinstalaciones pertenecientes al Yacimiento fijado para esta Inspección.</p></div></div><label className="field"><span>Buscar Inventario</span><input value={assetSearch} onChange={(event) => setAssetSearch(event.target.value)} placeholder="Código o nombre" /></label><label className="field"><span>Inventario</span><SearchableSelect value={newAssetId} onChange={(event) => setNewAssetId(event.target.value)} required><option value="">Seleccionar</option>{candidateAssets.map((asset) => <option key={asset.id} value={asset.id}>{asset.code} · {asset.name} · {asset.typeName}</option>)}</SearchableSelect></label><div className="form-actions"><button className="button primary" disabled={busy || !newAssetId}><Icon name="plus" />Agregar preventivo</button></div></form>} {visit && planningEditable && canManage && <form className="panel survey-add-target" onSubmit={addPreventiveAsset}><div className="panel-heading"><div><span className="eyebrow">PREVENTIVO</span><h2>Agregar Inventario sin pendiente previo</h2><p className="section-copy">Sólo se muestran Instalaciones y Subinstalaciones pertenecientes al Yacimiento fijado para esta Inspección.</p></div></div><label className="field"><span>Buscar Inventario</span><input value={assetSearch} onChange={(event) => setAssetSearch(event.target.value)} placeholder="Código o nombre" /></label><label className="field"><span>Inventario</span><SearchableSelect value={newAssetId} onChange={(event) => setNewAssetId(event.target.value)} required><option value="">Seleccionar</option>{candidateAssets.map((asset) => <option key={asset.id} value={asset.id}>{asset.code} · {asset.name} · {asset.typeName}</option>)}</SearchableSelect></label><div className="form-actions"><button className="button primary" disabled={busy || !newAssetId}><Icon name="plus" />Agregar preventivo</button></div></form>}
{visit && <article className="table-panel inspection-assets"><div className="table-summary"><strong>{visit.assets.length} incluidos</strong><span>{visit.checklist.excludedAssets} excluidos con trazabilidad</span></div>{visit.planningAssets.length === 0 ? <EmptyState title="Sin Inventarios" text="Generá el checklist o agregá un preventivo." /> : <div className="table-scroll"><table><thead><tr><th>Inventario</th><th>Origen</th><th>Estado</th><th>Motivo</th><th /></tr></thead><tbody>{visit.planningAssets.map((asset) => <tr key={asset.id}><td><Link className="history-asset-link" to={`/inventarios/${asset.id}`}><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></Link></td><td>{sourceLabel(asset.planningSource)}</td><td>{asset.included ? 'Incluido' : 'Excluido'}</td><td>{asset.exclusionReason ?? '—'}</td><td>{canManage && planningEditable && (asset.included ? <button type="button" className="button danger-outline compact" onClick={() => void excludeAsset(asset.id)}>Excluir</button> : <button type="button" className="button secondary compact" onClick={() => void reincludeAsset(asset.id)}>Reincorporar</button>)}</td></tr>)}</tbody></table></div>}</article>} {visit && <article className="table-panel inspection-assets"><div className="table-summary"><strong>{visit.assets.length} incluidos</strong><span>{visit.checklist.excludedAssets} excluidos con trazabilidad</span></div>{visit.planningAssets.length === 0 ? <EmptyState title="Sin Inventarios" text="Generá el checklist o agregá un preventivo." /> : <div className="table-scroll"><SortableTable><thead><tr><th>Inventario</th><th>Origen</th><th>Estado</th><th>Motivo</th><th /></tr></thead><tbody>{visit.planningAssets.map((asset) => <tr key={asset.id}><td><Link className="history-asset-link" to={`/inventarios/${asset.id}`}><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></Link></td><td>{sourceLabel(asset.planningSource)}</td><td>{asset.included ? 'Incluido' : 'Excluido'}</td><td>{asset.exclusionReason ?? '—'}</td><td>{canManage && planningEditable && (asset.included ? <button type="button" className="button danger-outline compact" onClick={() => void excludeAsset(asset.id)}>Excluir</button> : <button type="button" className="button secondary compact" onClick={() => void reincludeAsset(asset.id)}>Reincorporar</button>)}</td></tr>)}</tbody></SortableTable></div>}</article>}
{visit && canAssign && planningEditable && <form className="panel inspection-team-panel" onSubmit={saveTeam}><div className="panel-heading"><div><span className="eyebrow">EQUIPO</span><h2>Inspectores asignados</h2></div></div><label className="field"><span>Inspector responsable</span><SearchableSelect value={leadInspectorId} onChange={(event) => chooseLead(event.target.value)}><option value="">Seleccionar</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)} · {person.username}</option>)}</SearchableSelect></label><div className="inspection-team-grid">{assignees.map((person) => <label className={`inspection-member ${memberIds.has(person.id) ? 'selected' : ''}`} key={person.id}><input type="checkbox" checked={memberIds.has(person.id)} onChange={() => setMemberIds((current) => { const next = new Set(current); next.has(person.id) ? next.delete(person.id) : next.add(person.id); return next; })} disabled={person.id === leadInspectorId} /><span><strong>{personName(person)}</strong><small>{person.username}{person.id === leadInspectorId ? ' · Responsable' : ''}</small></span></label>)}</div><div className="form-actions"><button className="button primary" disabled={busy || !leadInspectorId}>Guardar equipo</button></div></form>} {visit && canAssign && planningEditable && <form className="panel inspection-team-panel" onSubmit={saveTeam}><div className="panel-heading"><div><span className="eyebrow">EQUIPO</span><h2>Inspectores asignados</h2></div></div><label className="field"><span>Inspector responsable</span><SearchableSelect value={leadInspectorId} onChange={(event) => chooseLead(event.target.value)}><option value="">Seleccionar</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)} · {person.username}</option>)}</SearchableSelect></label><div className="inspection-team-grid">{assignees.map((person) => <label className={`inspection-member ${memberIds.has(person.id) ? 'selected' : ''}`} key={person.id}><input type="checkbox" checked={memberIds.has(person.id)} onChange={() => setMemberIds((current) => { const next = new Set(current); next.has(person.id) ? next.delete(person.id) : next.add(person.id); return next; })} disabled={person.id === leadInspectorId} /><span><strong>{personName(person)}</strong><small>{person.username}{person.id === leadInspectorId ? ' · Responsable' : ''}</small></span></label>)}</div><div className="form-actions"><button className="button primary" disabled={busy || !leadInspectorId}>Guardar equipo</button></div></form>}
+2 -1
View File
@@ -1,3 +1,4 @@
import { SortableTable } from '../components/SortableTable';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router'; import { Link, useSearchParams } from 'react-router';
@@ -106,6 +107,6 @@ export function InspectionVisitsPage() {
</form> </form>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Cargando inspecciones…" /> : visits.length === 0 ? <EmptyState title="Sin inspecciones" text="No hay inspecciones que coincidan con el estado y contexto seleccionados." /> : <div className="table-panel"><div className="table-summary"><strong>{meta.total} inspección{meta.total === 1 ? '' : 'es'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><table><thead><tr><th>Inspección</th><th>Estado</th><th>Área / Operadora</th><th>Responsable</th><th>Fecha prevista</th><th>Inventario</th><th /></tr></thead><tbody>{visits.map((visit) => <tr key={visit.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{visit.code}</strong><small>{visit.operationalArea?.name ?? 'Sin Área'} · {visit.operatorCompany?.name ?? 'Sin Operadora'}</small></div></div></td><td><span className={`status-badge ${inspectionStatusClass(visit.status)}`}>{inspectionVisitStatusLabel(visit.status)}</span></td><td>{visit.operationalArea || visit.operatorCompany ? <span><strong className="table-primary">{visit.operationalArea?.name ?? 'Sin Área'}</strong><small className="cell-subtext">{visit.operatorCompany?.name ?? 'Sin Operadora'}</small></span> : <span className="muted">Sin contexto</span>}</td><td>{visit.leadInspector ? `${visit.leadInspector.firstName} ${visit.leadInspector.lastName}` : 'Sin asignar'}</td><td><span className="survey-date-range">{formatDate(visit.plannedStartAt)}<small>inicio planificado</small></span></td><td>{visit.scopeAsset ? <Link className="text-link" to={`/inventarios/${visit.scopeAsset.id}`}>{visit.scopeAsset.name}<small className="block-muted">{visit.scopeAsset.code}</small></Link> : <><strong>{visit.assetCount} registro{visit.assetCount === 1 ? '' : 's'}</strong><small className="block-muted">{visit.memberCount} integrante{visit.memberCount === 1 ? '' : 's'}</small></>}</td><td className="action-cell"><Link className="icon-button" to={`/inspecciones/${visit.id}`} aria-label={`Abrir ${visit.code}`}><Icon name="chevron" /></Link></td></tr>)}</tbody></table></div><div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>} {loading ? <LoadingBlock label="Cargando inspecciones…" /> : visits.length === 0 ? <EmptyState title="Sin inspecciones" text="No hay inspecciones que coincidan con el estado y contexto seleccionados." /> : <div className="table-panel"><div className="table-summary"><strong>{meta.total} inspección{meta.total === 1 ? '' : 'es'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><SortableTable><thead><tr><th>Inspección</th><th>Estado</th><th>Área / Operadora</th><th>Responsable</th><th>Fecha prevista</th><th>Inventario</th><th /></tr></thead><tbody>{visits.map((visit) => <tr key={visit.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{visit.code}</strong><small>{visit.operationalArea?.name ?? 'Sin Área'} · {visit.operatorCompany?.name ?? 'Sin Operadora'}</small></div></div></td><td><span className={`status-badge ${inspectionStatusClass(visit.status)}`}>{inspectionVisitStatusLabel(visit.status)}</span></td><td>{visit.operationalArea || visit.operatorCompany ? <span><strong className="table-primary">{visit.operationalArea?.name ?? 'Sin Área'}</strong><small className="cell-subtext">{visit.operatorCompany?.name ?? 'Sin Operadora'}</small></span> : <span className="muted">Sin contexto</span>}</td><td>{visit.leadInspector ? `${visit.leadInspector.firstName} ${visit.leadInspector.lastName}` : 'Sin asignar'}</td><td><span className="survey-date-range">{formatDate(visit.plannedStartAt)}<small>inicio planificado</small></span></td><td>{visit.scopeAsset ? <Link className="text-link" to={`/inventarios/${visit.scopeAsset.id}`}>{visit.scopeAsset.name}<small className="block-muted">{visit.scopeAsset.code}</small></Link> : <><strong>{visit.assetCount} registro{visit.assetCount === 1 ? '' : 's'}</strong><small className="block-muted">{visit.memberCount} integrante{visit.memberCount === 1 ? '' : 's'}</small></>}</td><td className="action-cell"><Link className="icon-button" to={`/inspecciones/${visit.id}`} aria-label={`Abrir ${visit.code}`}><Icon name="chevron" /></Link></td></tr>)}</tbody></SortableTable></div><div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
</section>; </section>;
} }
+2 -2
View File
@@ -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>}

Some files were not shown because too many files have changed in this diff Show More