Compare commits

...
Author SHA1 Message Date
admin 2455a89c3e test(android): fijar selección estructural por código 2026-09-11 12:43:11 -03:00
DH Android Finalizer 2a99183d31 fix(android): usar código estructural para Instalación y Subinstalación 2026-09-11 15:42:40 +00:00
admin 675d01bea7 chore: corregir clasificación estructural Android 2026-09-11 12:42:32 -03:00
admin 26d79f1e4d docs(android): fijar checklist de validación final 0.19.0 2026-09-11 12:37:16 -03:00
DH Android Finalizer 496bb785c1 fix(android): preservar ViewModel estable en release final 2026-09-11 15:36:42 +00:00
admin f513817388 chore: restaurar MainViewModel y aplicar cambios mínimos 0.19.0 2026-09-11 12:36:32 -03:00
admin 92a21d8f15 fix(android): limpiar firma y terminología final de Actas 2026-09-11 12:35:02 -03:00
admin 753fa9915f test(android): actualizar metadata de release 0.19.0 2026-09-11 12:33:09 -03:00
admin ec0ef732f7 test: actualizar contrato Android a 0.19.0 2026-09-11 12:27:42 -03:00
DH Android Finalizer 87adf71114 android-finalizer: finish Spanish visible copy 2026-09-11 15:24:59 +00:00
admin 0a856a1857 chore: completar limpieza de castellano Android 2026-09-11 12:24:51 -03:00
admin 247c652d40 fix(android): eliminar anglicismo visible en firma 2026-09-11 12:24:33 -03:00
admin b5ee17ce95 docs(android): documentar release final 0.19.0 2026-09-11 12:24:21 -03:00
admin cca2d38b98 test(android): fijar flujo final de inspección y castellano 2026-09-11 12:24:06 -03:00
DH Android Finalizer ebce2bb957 android-finalizer: cleanup final and remove temporary workflow 2026-09-11 15:23:15 +00:00
admin 963485618b chore: cerrar finalizador temporal Android 2026-09-11 12:23:06 -03:00
DH Android Finalizer 8f051158c1 android-finalizer: apply final field flow and Spanish UX 2026-09-11 15:21:04 +00:00
admin 2d8425dec5 chore: corregir ejecutor final Android 2026-09-11 12:20:55 -03:00
admin cfae2bb9ac chore: aplicar cierre final Android de campo 2026-09-11 12:19:50 -03:00
admin 5d8949d7d2 fix(android): mostrar estados de inspección en castellano 2026-09-11 12:18:25 -03:00
admin 304adcd9b0 feat(android): centralizar estados visibles en castellano 2026-09-11 12:17:52 -03:00
admin 20dd110928 release(android): preparar 0.19.0 final de campo 2026-09-11 12:17:35 -03:00
admin 80ffb35065 Merge pull request #51 from enlineawork/fix/yacimiento-companies-admin-advanced
fix(web): empresas en yacimientos y acceso avanzado de administrador
2026-09-11 11:01:30 -03:00
admin 6f79abbcff fix(web): mostrar empresas heredadas en yacimientos y proteger avanzado 2026-09-11 10:56:16 -03:00
admin d18375b85b fix(web): reservar ficha avanzada al administrador 2026-09-11 10:55:26 -03:00
admin 85a84e0f63 fix(web): centralizar acceso a administración avanzada 2026-09-11 10:55:08 -03:00
admin 03f42209b9 Merge pull request #50 from enlineawork/feature/territorial-simple-profile
feat(web): simplificar fichas territoriales
2026-09-11 10:39:50 -03:00
17 changed files with 324 additions and 92 deletions
+15
View File
@@ -0,0 +1,15 @@
# Checklist final Android 0.19.0
La candidata sólo se promueve si el mismo SHA cumple:
- iniciar Inspección;
- crear/abrir Acta;
- seleccionar o crear Instalación/Subinstalación;
- exigir GPS + fotografía para Inventario nacido en campo antes de Hallazgos;
- registrar Hallazgos sobre el elemento seleccionado;
- cerrar contenido del Acta y mostrar **Pendiente de firma**;
- completar firma del Inspector y manifestación/firma o negativa de la empresa;
- mostrar el Acta como **Firmada y cerrada**;
- permitir cerrar la Inspección sólo con todas las Actas activas firmadas y cerradas;
- no exponer estados internos en inglés en la interfaz de campo;
- Android CI / RC, DH V2 CI y auditoría de dependencias en verde.
+57 -26
View File
@@ -1,40 +1,71 @@
# DH Inspección Android · release Campo Moderno 0.18.0
# DH Inspección Android · release final de campo 0.19.0
## Candidata vigente
- Fase funcional: **Campo Moderno / UX dinámica de Inventario y Actas**.
- `versionName`: **0.18.0**.
- `versionCode`: **27**.
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
- `versionName`: **0.19.0**.
- `versionCode`: **28**.
- Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- API: `https://dhv2.korexlabs.com/api/v3/`.
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
## Novedades de campo 0.18.0
## Procedimiento operativo validado
La APK adopta una capa visual Material 3 propia de DH: superficies más limpias, jerarquía visual más clara, botones y estados más legibles, tarjetas con menor ruido y una navegación consistente entre Inspección, Actas e Inventario.
La APK 0.19.0 fija como recorrido principal de campo:
La carga de una Subinstalación mantiene la integridad del modelo físico pero reduce pasos:
1. **Iniciar Inspección**. Al iniciarla se habilitan Actas, Hallazgos e Inventario de campo.
2. **Crear o abrir un Acta**. Puede haber varias Actas dentro de una misma Inspección, pero sólo una puede permanecer en elaboración al mismo tiempo.
3. **Agregar Hallazgos** sobre una Instalación o Subinstalación seleccionada.
4. Si el elemento ya existe, se lo selecciona desde el Inventario del Área/Yacimiento de la Inspección.
5. Si no existe, se da de alta desde campo sin abandonar el Acta.
6. Un elemento nuevo debe completar **GPS + fotografía** antes de poder recibir Hallazgos.
7. Al terminar el contenido del Acta se usa **Cerrar Acta y dejar pendiente de firma**. Desde ese momento el contenido queda inmutable y el estado visible es **Pendiente de firma**.
8. La firma del Inspector y la manifestación/firma o negativa de la empresa completan el Acta, que pasa a **Firmada y cerrada**.
9. La Inspección sólo puede cerrarse cuando todas sus Actas no canceladas están firmadas y cerradas.
1. el buscador de **Instalaciones padre** ahora consulta mientras el inspector escribe;
2. cada coincidencia aparece como tarjeta seleccionable y tocarla avanza directamente a la identificación de la Subinstalación;
3. la clasificación técnica continúa filtrada por compatibilidad con la Instalación elegida;
4. **Guardar y tomar foto** captura GPS y abre la cámara automáticamente.
La nomenclatura técnica interna de API (`DRAFT`, `LOCKED`, `SEALED`, etc.) no se muestra al inspector: la interfaz usa textos operativos en castellano.
Se conserva la regla de que un elemento nacido en campo no puede recibir Hallazgos hasta contar con GPS y fotografía.
## Alta de Instalaciones y Subinstalaciones
## Actas en Android
El alta móvil queda alineada con el Dashboard y con las validaciones del servidor.
El espacio de Actas fue rediseñado para mostrar con claridad:
### Instalación
- contexto de la Inspección y Yacimiento;
- estado de cada Acta y cantidad de Hallazgos;
- urgencia mediante selección explícita;
- responsable de empresa, bloqueo, firmas, manifestación y sellado;
- condición necesaria para cerrar la Inspección.
- contexto territorial: Yacimiento/Área de la Inspección;
- clasificación técnica compatible;
- **Nombre técnico** obligatorio;
- Nombre habitual opcional;
- Descripción opcional;
- atributos técnicos definidos por el tipo; sólo los marcados como obligatorios bloquean el guardado;
- código DH generado por el sistema cuando corresponde;
- GPS de la tablet;
- fotografía obligatoria antes de crear Hallazgos.
El listado que consume Android usa un read-model móvil liviano (`/inspection-visits/:visitId/acts/mobile`). Esta lectura no depende del módulo documental de oficina ni de `inspection_reports`, de modo que entrar a una Inspección en campo no queda acoplado a la proyección de Informes/GEDO.
### Subinstalación
Además de lo anterior requiere elegir primero la **Instalación padre**. El buscador consulta mientras se escribe y cada resultado es seleccionable tocando la tarjeta.
La clasificación y los atributos se obtienen dinámicamente desde el mismo catálogo administrado por el Dashboard; la APK no mantiene listas técnicas paralelas.
## Actas y firma
La terminología visible se simplifica:
- `DRAFT`**En elaboración**;
- `LOCKED`**Pendiente de firma**;
- `SEALED`**Firmada y cerrada**.
Cerrar el contenido no cierra automáticamente la Inspección y tampoco obliga a terminar la firma en ese instante: puede continuarse con otra Acta. Una vez cerrado el contenido, el Acta es inmutable.
Después de la firma/cierre definitivo se mantiene el circuito documental existente: PDF del Acta e informe INF editable (DOCX) para oficina.
## Castellano e identidad visual
Se revisaron las pantallas activas de campo para evitar exponer estados y opciones internas en inglés. Entre otros cambios: Pasaporte/Otro en documentos, estados de Inspección/Acta/Hallazgo en castellano, Correo electrónico, Huella de integridad y terminología de cierre orientada al usuario.
La pantalla de ingreso y el desbloqueo biométrico utilizan la identidad visual de Mendoza ya incorporada al proyecto.
## Barrera obligatoria
@@ -42,16 +73,16 @@ Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar `And
1. validación de identidad, HTTPS y políticas básicas del manifest;
2. Android lint;
3. unit tests Android reales, con verificación de que exista al menos una prueba ejecutada;
3. pruebas unitarias Android reales, con verificación de que exista al menos una prueba ejecutada;
4. `assembleDebug`;
5. `assembleRelease` para comprobar que la variante productiva compile;
6. empaquetado del APK debug con SHA-256 y metadata de commit/versionado.
Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de promover el cambio.
Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de promover cambios que afecten al servidor.
## Firma release
La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero la APK productiva final debe firmarse con la clave histórica antes de instalarse como actualización de `com.korexlabs.dhinspeccion`.
La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero una APK productiva que deba actualizar `com.korexlabs.dhinspeccion` tiene que firmarse con la clave histórica.
No se debe crear una clave nueva para resolver una falta de acceso: eso rompería la continuidad de actualización de tablets que ya tengan una versión firmada con la clave anterior.
@@ -60,7 +91,7 @@ No se debe crear una clave nueva para resolver una falta de acceso: eso romperí
Antes de distribuir una APK productiva:
- todas las barreras de CI del SHA exacto deben estar verdes;
- comprobar certificado/huella de firma contra la versión histórica;
- realizar actualización sobre al menos una tablet con la versión productiva anterior;
- ejecutar smoke funcional contra el entorno objetivo: login, abrir Inspección, abrir Actas sin error, crear Acta, búsqueda dinámica de Instalación, selección de padre, alta de Subinstalación, GPS/foto, Hallazgo y cierre;
- comprobar certificado/huella de firma contra la versión histórica si se distribuirá como actualización productiva;
- realizar actualización sobre al menos una tablet con la versión productiva anterior cuando corresponda;
- ejecutar smoke funcional: ingreso, iniciar Inspección, crear Acta, seleccionar/crear Instalación o Subinstalación, GPS/foto, Hallazgo, cerrar Acta → Pendiente de firma, firma/manifestación, cierre de Acta y cierre de Inspección;
- registrar el SHA Git y SHA-256 de la APK distribuida.
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 27
versionName = "0.18.0"
versionCode = 28
versionName = "0.19.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -169,7 +169,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return
}
if (acts.any { it.status == "DRAFT" }) {
error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente."
error = "Ya existe un Acta en elaboración. Cerrala o cancelala antes de crear la siguiente."
return
}
launchBusy {
@@ -241,6 +241,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
latitude: Double,
longitude: Double,
accuracyM: Double?,
description: String? = null,
) {
val visitId = visit?.id ?: return
if (visit?.status != "IN_PROGRESS") {
@@ -258,6 +259,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
familyId = familyId,
name = name.trim(),
commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
description = description?.trim()?.takeIf { it.isNotBlank() },
attributes = attributes,
deviceLatitude = latitude,
deviceLongitude = longitude,
@@ -340,7 +342,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val assetId = selectedFieldAsset?.asset?.id ?: return
val draft = selectedDraftAct()
if (draft == null) {
error = "Creá o seleccioná el Acta en borrador antes de registrar Hallazgos."
error = "Creá o seleccioná el Acta en elaboración antes de registrar Hallazgos."
return
}
launchBusy {
@@ -362,7 +364,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val assetId = selectedFieldAsset?.asset?.id ?: return
val actId = selectedDraftAct()?.id
if (actId == null) {
error = "No hay un Acta en borrador seleccionada."
error = "No hay un Acta en elaboración seleccionada."
return
}
if (description.isBlank()) {
@@ -471,7 +473,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
absenceReason = reason.trim(),
),
)
notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de sellar el Acta."
notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de firmar y cerrar el Acta."
}
}
@@ -480,12 +482,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy {
actClosure = actsRepository.lock(actId)
refreshSelectedActInternal(actId)
notice = "Acta bloqueada. Su contenido quedó inmutable; ahora deben resolverse las firmas y manifestaciones."
notice = "Acta cerrada y pendiente de firma. Su contenido quedó inmutable; las firmas y manifestaciones pueden completarse a continuación."
}
}
fun reopenSelectedAct() {
error = "Un Acta bloqueada es inmutable y no puede volver a borrador."
error = "Un Acta pendiente de firma es inmutable y no puede volver a edición."
}
fun signSelectedActAsInspector(
@@ -546,7 +548,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false)
clearFindingState()
notice = "${selectedAct?.code ?: "Acta"} SELLADA e inmutable. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
}
}
@@ -555,7 +557,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy {
visit = actsRepository.closeVisit(visitId)
loadVisitsInternal()
notice = "Inspección cerrada. Todas sus Actas quedaron SELLADAS y disponibles para el circuito de oficina."
notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina."
}
}
@@ -185,7 +185,7 @@ fun FieldFindingScreen(model: MainViewModel) {
Text(asset.name, fontWeight = FontWeight.Bold)
Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall)
Text(
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "OK" else "pendiente"}",
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "completo" else "pendiente"}",
color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
@@ -210,7 +210,7 @@ fun FieldFindingScreen(model: MainViewModel) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold)
Text("Gravedad: ${finding.severity ?: "s/d"} · ${finding.status}", style = MaterialTheme.typography.bodySmall)
Text("Gravedad: ${finding.severity ?: "s/d"} · ${findingStatusLabelEs(finding.status)}", style = MaterialTheme.typography.bodySmall)
Text(
"Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}",
style = MaterialTheme.typography.bodySmall,
@@ -3,11 +3,13 @@ package com.korexlabs.dhinspeccion.ui
import android.content.Context
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
@@ -29,7 +31,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
@@ -37,6 +41,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.R
private const val BIOMETRIC_PREFS = "dh_v2_biometric"
private const val BIOMETRIC_ENABLED = "enabled"
@@ -105,7 +110,13 @@ private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Uni
var passwordVisible by rememberSaveable { mutableStateOf(false) }
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Image(
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
contentDescription = "Gobierno de Mendoza",
modifier = Modifier.fillMaxWidth().height(170.dp),
contentScale = ContentScale.Fit,
)
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Aplicación de campo · Dirección de Hidrocarburos")
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
@@ -113,7 +124,7 @@ private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Uni
OutlinedTextField(
value = identifier,
onValueChange = { identifier = it },
label = { Text("Usuario o email") },
label = { Text("Usuario o correo electrónico") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
@@ -198,6 +209,12 @@ private fun BiometricUnlockScreen(
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
contentDescription = "Gobierno de Mendoza",
modifier = Modifier.fillMaxWidth().height(140.dp),
contentScale = ContentScale.Fit,
)
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Ingresá con tu huella")
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
@@ -215,7 +215,7 @@ private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(visit.code, fontWeight = FontWeight.Bold)
Text(visit.status)
Text(visitStatusLabelEs(visit.status))
}
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
@@ -245,7 +245,7 @@ fun ModernMobileActsScreen(
) {
Text(
if (newActUrgency == "URGENT") {
"El plazo urgente se computará desde el Acta conforme a la política institucional vigente al bloquearla."
"El plazo urgente se computará desde el Acta conforme a la política institucional vigente al cerrar su contenido."
} else {
"El plazo no urgente comienza con la oficialización GEDO, no al crear el Acta."
},
@@ -319,12 +319,12 @@ fun ModernMobileActsScreen(
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->
FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(kind) })
FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(documentTypeLabelEs(kind)) })
}
}
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(email, { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(email, { email = it }, label = { Text("Correo electrónico") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
Button(
onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) },
@@ -353,17 +353,17 @@ fun ModernMobileActsScreen(
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.width(8.dp))
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text(
"Al bloquear el Acta, su contenido y sus Hallazgos quedan inmutables.",
"Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. Podés firmarla ahora o continuar con otra Acta.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { model.prepareSelectedAct() },
enabled = !model.busy && closure?.responsible != null,
modifier = Modifier.fillMaxWidth(),
) { Text("Finalizar y BLOQUEAR Acta") }
) { Text("Cerrar Acta y dejar pendiente de firma") }
}
}
}
@@ -379,10 +379,10 @@ fun ModernMobileActsScreen(
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.secondary)
Spacer(Modifier.width(8.dp))
Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Acta pendiente de firma", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text("El contenido ya es inmutable. Restan firmas y manifestaciones para poder sellarla.")
closure?.closure?.preparedSha256?.let { Text("Hash · $it", style = MaterialTheme.typography.bodySmall) }
Text("El contenido ya está cerrado e inmutable. Completá la firma del inspector y la manifestación de la empresa cuando corresponda.")
closure?.closure?.preparedSha256?.let { Text("Huella de integridad · $it", style = MaterialTheme.typography.bodySmall) }
HorizontalDivider()
Text("Firma del inspector", fontWeight = FontWeight.Bold)
@@ -403,7 +403,7 @@ fun ModernMobileActsScreen(
val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
"REFUSED" -> "Negativa a firmar"
else -> companyOutcome.status
else -> humanBackendLabelEs(companyOutcome.status)
}
SuccessLine(detail)
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
@@ -413,7 +413,7 @@ fun ModernMobileActsScreen(
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error)
Text(
"La ausencia no resuelve la manifestación. Debe registrarse firma o negativa antes de sellar.",
"La ausencia no resuelve la manifestación. Debe registrarse firma o negativa antes de firmar y cerrar el Acta.",
Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
)
@@ -462,7 +462,7 @@ fun ModernMobileActsScreen(
) {
Icon(Icons.Filled.CheckCircle, null)
Spacer(Modifier.width(6.dp))
Text("SELLAR Acta definitivamente")
Text("Firmar y cerrar Acta")
}
} else {
Text(
@@ -480,8 +480,8 @@ fun ModernMobileActsScreen(
Row(Modifier.padding(18.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) {
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text("Acta SELLADA e inmutable", fontWeight = FontWeight.Bold)
Text("Desde este sellado se genera el PDF del Acta y el INF Word editable para el Inspector.")
Text("Acta firmada y cerrada", fontWeight = FontWeight.Bold)
Text("Desde este cierre se genera el PDF del Acta y el informe INF editable (DOCX) para el Inspector.")
closure?.closure?.finalSha256?.let { Text("SHA-256 · $it", style = MaterialTheme.typography.bodySmall) }
}
}
@@ -503,10 +503,10 @@ fun ModernMobileActsScreen(
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (pendingActs.isEmpty()) {
SuccessLine("Todas las Actas están SELLADAS")
SuccessLine("Todas las Actas están firmadas y cerradas")
} else {
Text(
"Falta SELLAR: ${pendingActs.joinToString { it.code }}",
"Falta firmar y cerrar: ${pendingActs.joinToString { it.code }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
@@ -531,13 +531,7 @@ private fun SuccessLine(text: String) {
}
}
private fun modernActStatusLabel(status: String): String = when (status) {
"DRAFT" -> "Borrador"
"LOCKED" -> "Bloqueada"
"SEALED" -> "Sellada"
"CANCELLED" -> "Cancelada"
else -> status
}
private fun modernActStatusLabel(status: String): String = actStatusLabelEs(status)
private fun hasModernActLocation(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
@@ -274,7 +274,7 @@ private fun ModernChecklistCard(visit: VisitDetail) {
)
}
if (checklist.stale) {
Text("El checklist requiere actualización.", color = MaterialTheme.colorScheme.error)
Text("La lista de control requiere actualización.", color = MaterialTheme.colorScheme.error)
}
}
}
@@ -296,6 +296,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("") }
var name by rememberSaveable(visit.id) { mutableStateOf("") }
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
var description by rememberSaveable(visit.id) { mutableStateOf("") }
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var familySearch by rememberSaveable(visit.id) { mutableStateOf("") }
@@ -311,6 +312,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
fun resetForm() {
name = ""
commonName = ""
description = ""
selectedTypeId = null
selectedFamilyId = null
familySearch = ""
@@ -444,6 +446,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
familyId = selectedFamilyId,
name = name,
commonName = commonName,
description = description,
attributes = buildModernAttributes(type, attributeValues),
latitude = geo.latitude,
longitude = geo.longitude,
@@ -800,18 +803,26 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nombre o código visible *") },
supportingText = { Text("Usá lo que figura en placa o identifica el elemento en campo.") },
label = { Text("Nombre técnico *") },
supportingText = { Text("Usá la denominación técnica del elemento. El código DH se genera automáticamente.") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = commonName,
onValueChange = { commonName = it },
label = { Text("Nombre habitual") },
label = { Text("Nombre habitual (opcional)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Descripción (opcional)") },
supportingText = { Text("Dato equivalente al alta desde el panel web.") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
selectedType?.attributes?.forEach { definition ->
OutlinedTextField(
@@ -1066,20 +1077,14 @@ private fun ModernStepHeader(step: Int, title: String) {
private fun modernItemTypeCode(item: FieldInventoryItem): String {
val type = item.type ?: return ""
return modernNormalize(type.typeName ?: type.name)
return modernNormalize(type.code.ifBlank { type.typeName ?: type.name })
}
private fun modernNormalize(value: String): String = value.trim().lowercase()
.replace('ó', 'o').replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ú', 'u')
.replace("_", "").replace("-", "").replace(" ", "")
private fun modernStatusLabel(status: String): String = when (status) {
"PLANNED" -> "Planificada"
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
else -> status
}
private fun modernStatusLabel(status: String): String = visitStatusLabelEs(status)
private fun buildModernAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition ->
@@ -49,7 +49,7 @@ fun SignaturePad(
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold)
Text(
"Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora al hash del Acta.",
"Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora a la huella de integridad del Acta.",
style = MaterialTheme.typography.bodySmall,
)
Canvas(
@@ -0,0 +1,50 @@
package com.korexlabs.dhinspeccion.ui
import java.util.Locale
internal fun visitStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"PLANNED" -> "Planificada"
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
null, "" -> "Sin estado"
else -> humanBackendLabelEs(status)
}
internal fun actStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"DRAFT" -> "En elaboración"
"LOCKED" -> "Pendiente de firma"
"SEALED" -> "Firmada y cerrada"
"CLOSED" -> "Cerrada"
"RECTIFIED" -> "Rectificada"
"CANCELLED" -> "Cancelada"
null, "" -> "Sin estado"
else -> humanBackendLabelEs(status)
}
internal fun findingStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"OPEN" -> "Abierto"
"PENDING" -> "Pendiente"
"COMPANY_RESPONSE_PENDING" -> "Pendiente de respuesta de la empresa"
"COMPANY_RESPONDED" -> "Respondido por la empresa"
"VERIFICATION_PENDING" -> "Pendiente de verificación"
"VERIFICATION_SCHEDULED" -> "Verificación programada"
"READY_TO_CLOSE" -> "Listo para cierre"
"CLOSED" -> "Cerrado"
"CANCELLED" -> "Cancelado"
null, "" -> "Sin estado"
else -> humanBackendLabelEs(status)
}
internal fun documentTypeLabelEs(type: String): String = when (type.uppercase(Locale.ROOT)) {
"DNI" -> "DNI"
"CUIL" -> "CUIL"
"PASSPORT" -> "Pasaporte"
"OTHER" -> "Otro"
else -> humanBackendLabelEs(type)
}
internal fun humanBackendLabelEs(value: String): String {
val clean = value.trim().replace('_', ' ').replace('-', ' ').lowercase(Locale("es", "AR"))
return clean.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale("es", "AR")) else it.toString() }
}
@@ -0,0 +1,57 @@
package com.korexlabs.dhinspeccion
import java.io.File
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FinalFieldFlowContractTest {
private val visit = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt").readText()
private val acts = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt").readText()
private val findings = File("src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt").readText()
private val home = File("src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt").readText()
private val labels = File("src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt").readText()
@Test
fun inspectionActFindingFlowMatchesFieldProcedure() {
assertTrue(visit.contains("Iniciar inspección"))
assertTrue(acts.contains("Crear nueva Acta"))
assertTrue(acts.contains("Agregar Hallazgo"))
assertTrue(visit.contains("Instalación"))
assertTrue(visit.contains("Subinstalación"))
assertTrue(visit.contains("Guardar y tomar foto"))
assertTrue(acts.contains("Cerrar Acta y dejar pendiente de firma"))
assertTrue(labels.contains("\"LOCKED\" -> \"Pendiente de firma\""))
}
@Test
fun fieldCreateKeepsDashboardEquivalentMinimumAndCaptureBarrier() {
assertTrue(visit.contains("Nombre técnico *"))
assertTrue(visit.contains("Nombre habitual (opcional)"))
assertTrue(visit.contains("Descripción (opcional)"))
assertTrue(visit.contains("familyReady"))
assertTrue(visit.contains("attributesReady"))
assertTrue(visit.contains("GPS + foto"))
assertTrue(visit.contains("La foto es obligatoria antes de registrar Hallazgos"))
}
@Test
fun structuralSelectionUsesTheServerTypeCode() {
assertTrue(visit.contains("type.code.ifBlank { type.typeName ?: type.name }"))
assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"instalacion\", \"subinstalacion\")"))
}
@Test
fun currentFieldUiDoesNotExposeBackendEnglishStates() {
assertTrue(home.contains("visitStatusLabelEs(visit.status)"))
assertTrue(findings.contains("findingStatusLabelEs(finding.status)"))
assertTrue(acts.contains("documentTypeLabelEs(kind)"))
assertTrue(labels.contains("\"PASSPORT\" -> \"Pasaporte\""))
assertTrue(labels.contains("\"OTHER\" -> \"Otro\""))
assertFalse(acts.contains("Finalizar y BLOQUEAR Acta"))
assertFalse(acts.contains("Acta BLOQUEADA"))
assertFalse(acts.contains("SELLAR Acta"))
assertFalse(acts.contains("Acta SELLADA"))
assertFalse(acts.contains("Text(\"Email\")"))
}
}
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test
fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(27, BuildConfig.VERSION_CODE)
assertEquals("0.18.0-debug", BuildConfig.VERSION_NAME)
assertEquals(28, BuildConfig.VERSION_CODE)
assertEquals("0.19.0-debug", BuildConfig.VERSION_NAME)
}
@Test
+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', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 27/);
assert.match(gradle, /versionName = "0\.18\.0"/);
assert.match(gradle, /versionCode = 28/);
assert.match(gradle, /versionName = "0\.19\.0"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
});
+26
View File
@@ -0,0 +1,26 @@
import type { AuthUser } from '../lib/api';
function normalizeRole(value: string) {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
const ADMIN_ROLES = new Set([
'admin',
'administrator',
'administrador',
'superadmin',
'super admin',
'superadministrator',
'super administrator',
'superadministrador',
'super administrador',
]);
export function hasAdministratorRole(user: AuthUser | null) {
return Boolean(user?.roles.some((role) => ADMIN_ROLES.has(normalizeRole(role))));
}
+7 -1
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { hasAdministratorRole } from '../auth/adminAccess';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { getAsset } from '../lib/api';
import type { AssetDetail } from '../lib/api';
@@ -10,6 +12,7 @@ import { TerritorialInventoryPage, isTerritorialInventoryAsset } from './Territo
export function InventoryDetailPage() {
const { id } = useParams();
const [params] = useSearchParams();
const { user } = useAuth();
const [asset, setAsset] = useState<AssetDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -31,8 +34,11 @@ export function InventoryDetailPage() {
if (loading) return <LoadingBlock label="Cargando registro…" />;
if (!asset) return <Alert>{error || 'No se pudo cargar el registro.'}</Alert>;
const advanced = params.get('advanced') === '1';
const advancedRequested = params.get('advanced') === '1';
const advancedAllowed = hasAdministratorRole(user);
const advanced = advancedRequested && advancedAllowed;
const isCompany = asset.type.code.toLowerCase() === 'empresa';
if (isCompany && !advanced) {
return <CompanyInventoryPage initialAsset={asset} />;
}
+39 -10
View File
@@ -2,17 +2,19 @@ import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { hasAdministratorRole } from '../auth/adminAccess';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel';
import { assetStatusClass, assetStatusLabel } from '../features/assets/assetPresentation';
import {
getAssetLineage,
listAreaCompanyRelations,
listAssetTreeChildren,
listInspectionVisits,
updateAsset,
} from '../lib/api';
import type { AreaCompanyRelation, AssetDetail, AssetListItem } from '../lib/api';
import type { AreaCompanyRelation, AssetDetail, AssetLineageItem, AssetListItem } from '../lib/api';
import {
listInspectionActsGlobalF4,
type InspectionActStatusF4,
@@ -31,8 +33,12 @@ function normalized(value: string) {
.trim();
}
function tokensForType(type: { code: string; name: string }) {
return new Set(`${normalized(type.code)} ${normalized(type.name)}`.split(' ').filter(Boolean));
}
function typeTokens(asset: AssetDetail) {
return new Set(`${normalized(asset.type.code)} ${normalized(asset.type.name)}`.split(' ').filter(Boolean));
return tokensForType(asset.type);
}
export function isTerritorialInventoryAsset(asset: AssetDetail) {
@@ -44,6 +50,14 @@ function isOperationalArea(asset: AssetDetail) {
return typeTokens(asset).has('area');
}
function isYacimiento(asset: AssetDetail) {
return typeTokens(asset).has('yacimiento');
}
function lineageArea(lineage: AssetLineageItem[]) {
return [...lineage].reverse().find((item) => tokensForType(item.type).has('area')) ?? null;
}
function relationRoleLabel(role: AreaCompanyRelation['relationRole']) {
if (role === 'OPERATOR') return 'Operadora';
if (role === 'TECHNICAL_OPERATOR') return 'Operadora técnica';
@@ -57,7 +71,7 @@ export function TerritorialInventoryPage({ initialAsset }: { initialAsset: Asset
const [params, setParams] = useSearchParams();
const requestedTab = params.get('tab') as TerritoryTab | null;
const tab: TerritoryTab = requestedTab === 'history' ? 'history' : 'summary';
const { hasPermission } = useAuth();
const { user, hasPermission } = useAuth();
const canEdit = hasPermission('assets.update');
const canCreate = hasPermission('assets.create');
const canReadHistory = hasPermission('assets.read_history');
@@ -65,10 +79,12 @@ export function TerritorialInventoryPage({ initialAsset }: { initialAsset: Asset
const canReadActs = hasPermission('inspection_acts.read');
const canReadReports = hasPermission('inspection_reports.read');
const canReadInspections = hasPermission('inspections.read');
const canAdvanced = hasAdministratorRole(user);
const [asset, setAsset] = useState(initialAsset);
const [children, setChildren] = useState<AssetListItem[]>([]);
const [childrenHasMore, setChildrenHasMore] = useState(false);
const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
const [relations, setRelations] = useState<AreaCompanyRelation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -80,18 +96,31 @@ export function TerritorialInventoryPage({ initialAsset }: { initialAsset: Asset
const [metrics, setMetrics] = useState({ activeInspections: 0, acts: 0, reports: 0 });
const area = isOperationalArea(asset);
const yacimiento = isYacimiento(asset);
const activeRelations = useMemo(() => relations.filter((item) => item.active), [relations]);
const relationAreaContext = useMemo(() => {
if (area) return { id: asset.id, code: asset.code, name: asset.name };
if (asset.operationalArea) return asset.operationalArea;
const ancestor = lineageArea(lineage);
return ancestor ? { id: ancestor.id, code: ancestor.code, name: ancestor.name } : null;
}, [area, asset.id, asset.code, asset.name, asset.operationalArea, lineage]);
const loadSummary = async () => {
setLoading(true);
setError('');
try {
const loadedLineage = await getAssetLineage(asset.id);
const ancestorArea = lineageArea(loadedLineage);
const relationAreaId = area
? asset.id
: asset.operationalArea?.id ?? ancestorArea?.id ?? null;
const [childPage, loadedRelations] = await Promise.all([
listAssetTreeChildren({ parentId: asset.id, limit: 100 }),
area && canReadRelations
? listAreaCompanyRelations({ areaId: asset.id, includeHistory: true })
relationAreaId && canReadRelations
? listAreaCompanyRelations({ areaId: relationAreaId, includeHistory: true })
: Promise.resolve([]),
]);
setLineage(loadedLineage);
setChildren(childPage.data);
setChildrenHasMore(childPage.meta.hasMore);
setRelations(loadedRelations);
@@ -223,10 +252,10 @@ export function TerritorialInventoryPage({ initialAsset }: { initialAsset: Asset
{childrenHasMore && <div className="territory-card-actions"><Link className="button secondary" to={`/inventarios?section=territory&parentId=${asset.id}`}>Ver contenido completo</Link></div>}
</article>
{area && canReadRelations && <article className="panel territory-card">
<div className="territory-card-heading"><div><span className="eyebrow">OPERACIÓN</span><h2>Empresas vinculadas</h2><p>Quién opera o participa actualmente en esta área.</p></div><span className="count-pill">{activeRelations.length}</span></div>
{loading ? <LoadingBlock label="Cargando empresas…" /> : activeRelations.length === 0 ? <div className="inline-empty">No hay empresas con vínculo vigente.</div> : <div className="territory-company-list">
{activeRelations.map((relation) => <Link to={`/inventarios/${relation.company.id}`} key={relation.id} className="territory-company-row"><span><strong>{relation.company.name}</strong><small>{relationRoleLabel(relation.relationRole)}</small></span><Icon name="chevron" size={16} /></Link>)}
{relationAreaContext && canReadRelations && <article className="panel territory-card">
<div className="territory-card-heading"><div><span className="eyebrow">OPERACIÓN</span><h2>Empresas vinculadas</h2><p>{area ? 'Quién opera o participa actualmente en esta área.' : yacimiento ? `Empresas que operan o participan en el Área ${relationAreaContext.name}, contexto operativo de este yacimiento.` : `Empresas vinculadas al Área ${relationAreaContext.name}.`}</p></div><span className="count-pill">{activeRelations.length}</span></div>
{loading ? <LoadingBlock label="Cargando empresas…" /> : activeRelations.length === 0 ? <div className="inline-empty">No hay empresas con vínculo vigente en el Área {relationAreaContext.name}.</div> : <div className="territory-company-list">
{activeRelations.map((relation) => <Link to={`/inventarios/${relation.company.id}`} key={relation.id} className="territory-company-row"><span><strong>{relation.company.name}</strong><small>{relationRoleLabel(relation.relationRole)}{!area ? ` · ${relation.area.name}` : ''}</small></span><Icon name="chevron" size={16} /></Link>)}
</div>}
</article>}
@@ -239,7 +268,7 @@ export function TerritorialInventoryPage({ initialAsset }: { initialAsset: Asset
</div>
</article>}
<div className="territory-advanced-link"><span>Los datos estructurales, procedencia, ubicación y opciones técnicas quedan fuera de la vista cotidiana.</span><Link to={`/inventarios/${asset.id}?advanced=1`}>Administración avanzada</Link></div>
{canAdvanced && <div className="territory-advanced-link"><span>Los datos estructurales, procedencia, ubicación y opciones técnicas quedan fuera de la vista cotidiana.</span><Link to={`/inventarios/${asset.id}?advanced=1`}>Administración avanzada</Link></div>}
</div>}
{tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={asset.id} />}