Compare commits

..
Author SHA1 Message Date
admin 1b151fe964 feat(web): simplificar ficha de instalación y subinstalación 2026-09-11 14:26:42 -03:00
admin c748aba650 style(web): compactar resumen de instalaciones 2026-09-11 14:25:43 -03:00
admin b8b578a3f0 Merge pull request #53 from enlineawork/release/android-0.19.0-final-field-flow
release(android): 0.19.0 flujo final de campo
2026-09-11 12:53:16 -03:00
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
17 changed files with 1370 additions and 415 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.
+55 -42
View File
@@ -1,8 +1,8 @@
# DH Inspección Android · release de campo 0.19.0
# DH Inspección Android · release final de campo 0.19.0
## Candidata vigente
- Fase funcional: **Campo final / Inspección → Acta → Inventario → Hallazgos → Firma**.
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
- `versionName`: **0.19.0**.
- `versionCode`: **28**.
- Application ID release: `com.korexlabs.dhinspeccion`.
@@ -11,48 +11,61 @@
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
## Procedimiento de campo validado
## Procedimiento operativo validado
1. La inspección se **planifica y asigna desde el tablero web**. La APK no crea inspecciones nuevas.
2. En la tablet el inspector abre una inspección asignada y, al llegar al lugar, pulsa **Iniciar inspección**.
3. Con la inspección en curso se crea o selecciona el **Acta** de trabajo. Sólo puede existir un Acta en borrador a la vez; una inspección puede contener varias Actas sucesivas.
4. Los **Hallazgos** se registran exclusivamente sobre **Instalaciones o Subinstalaciones**.
5. Si el elemento ya existe, se busca y selecciona desde Inventario de campo.
6. Si no existe, se da de alta desde la APK siguiendo la misma estructura que el tablero:
- Instalación dentro del Yacimiento de la inspección;
- Subinstalación dentro de una Instalación seleccionada;
- nombre técnico / identificación obligatorio;
- Código DH de campo autogenerado por el servidor;
- nombre habitual opcional;
- descripción y observaciones de alta opcionales;
- clasificación técnica obligatoria cuando el tipo la requiera;
- atributos técnicos dinámicos obligatorios según la configuración del tipo;
- Área y Operadora heredadas de la inspección planificada;
- ubicación del dispositivo y fotografía obligatorias antes de habilitar Hallazgos.
7. Sobre el elemento se selecciona un Hallazgo del catálogo contextual o **OTROS** cuando corresponda; se registra descripción, gravedad y evidencia fotográfica si aplica.
8. Al terminar el contenido se pulsa **Cerrar Acta y dejar pendiente de firma**. El contenido y los Hallazgos quedan inmutables y el estado visible pasa a **Pendiente de firma**.
9. Se registran las firmas y la manifestación de la empresa. La ausencia por sí sola no modifica el contenido cerrado: el Acta permanece pendiente hasta resolver el circuito de firma/manifestación conforme a la regla vigente.
10. Una vez resuelta la firma, el Acta queda **Firmada e inmutable**. La inspección sólo puede cerrarse cuando todas sus Actas no canceladas estén finalizadas.
La APK 0.19.0 fija como recorrido principal de campo:
## Castellano y presentación
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.
La navegación operativa de la APK se presenta en castellano. Los valores técnicos que la API conserva internamente (`DRAFT`, `LOCKED`, `SEALED`, `PLANNED`, etc.) no se muestran al inspector; se traducen a **Borrador**, **Pendiente de firma**, **Firmada**, **Planificada**, **En curso**, etc.
La nomenclatura técnica interna de API (`DRAFT`, `LOCKED`, `SEALED`, etc.) no se muestra al inspector: la interfaz usa textos operativos en castellano.
También se incorporó la marca oficial de Mendoza provista para esta release en las pantallas de acceso y desbloqueo biométrico.
## Alta de Instalaciones y Subinstalaciones
## Integridad del alta de Inventario
El alta móvil queda alineada con el Dashboard y con las validaciones del servidor.
El alta móvil no duplica los formularios administrativos del tablero. Conserva los datos necesarios para que el registro tenga identidad y trazabilidad en campo, mientras que los datos administrativos que ya vienen determinados por la inspección (Área y Operadora) se heredan automáticamente.
### Instalación
El servidor continúa siendo la autoridad para:
- 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.
- generar el Código DH `CAM-*` cuando el alta nace en campo;
- validar la jerarquía Yacimiento → Instalación → Subinstalación;
- exigir la familia/clasificación técnica compatible;
- validar atributos dinámicos configurados en el tablero;
- registrar ubicación de creación;
- impedir crear Hallazgos sobre un alta de campo que todavía no tenga ubicación y fotografía;
- conservar la trazabilidad si el alta provisional se fusiona posteriormente con un registro existente.
### 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
@@ -60,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, incluyendo contratos del procedimiento final;
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 incorporar la release a `main`.
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.
@@ -78,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 producción: ingreso, abrir inspección asignada, iniciar inspección, crear Acta, buscar Instalación/Subinstalación, alta de campo, ubicación/foto, Hallazgo, cerrar Acta a pendiente de firma, firma/finalización y cierre de inspección;
- 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.
@@ -161,7 +161,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val currentVisit = visit ?: return
val asset = selectedFieldAsset?.asset
if (currentVisit.status != "IN_PROGRESS") {
error = "La inspección debe estar en curso para crear un Acta."
error = "La Inspección debe estar en curso para crear un Acta."
return
}
if (urgency !in setOf("URGENT", "NON_URGENT")) {
@@ -169,7 +169,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return
}
if (acts.any { it.status == "DRAFT" }) {
error = "Ya existe un Acta en borrador. Cerrala antes de crear la siguiente."
error = "Ya existe un Acta en elaboración. Cerrala o cancelala antes de crear la siguiente."
return
}
launchBusy {
@@ -218,7 +218,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val visitId = visit?.id ?: return
launchBusy {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la inspección."
notice = "Inventario agregado a la Inspección."
reloadCurrentInventory(visitId)
val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
@@ -237,16 +237,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
familyId: String?,
name: String,
commonName: String?,
description: String?,
discoveryNotes: String?,
attributes: Map<String, Any?>,
latitude: Double,
longitude: Double,
accuracyM: Double?,
description: String? = null,
) {
val visitId = visit?.id ?: return
if (visit?.status != "IN_PROGRESS") {
error = "La inspección debe estar en curso para dar de alta Inventario."
error = "La Inspección debe estar en curso para dar de alta Inventario."
return
}
if (type.familyRequired && familyId == null) {
@@ -261,7 +260,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
name = name.trim(),
commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
description = description?.trim()?.takeIf { it.isNotBlank() },
discoveryNotes = discoveryNotes?.trim()?.takeIf { it.isNotBlank() },
attributes = attributes,
deviceLatitude = latitude,
deviceLongitude = longitude,
@@ -269,7 +267,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
deviceCapturedAt = Instant.now().toString(),
)
selectedFieldAsset = repository.createFieldAsset(visitId, request)
notice = "Inventario creado con ubicación. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
reloadCurrentInventory(visitId)
}
}
@@ -323,7 +321,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
)
selectedFieldAsset = selectedFieldAsset?.copy(capture = response.capture)
notice = if (response.capture.readyForFinding) {
"Captura completa: ubicación y fotografía registradas."
"Captura completa: GPS y fotografía registrados."
} else {
"Fotografía registrada."
}
@@ -344,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 {
@@ -366,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()) {
@@ -423,7 +421,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
description = description,
)
loadEvidenceInternal(findingId)
notice = "Evidencia fotográfica registrada con ubicación."
notice = "Evidencia fotográfica registrada con GPS."
}
}
@@ -475,7 +473,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
absenceReason = reason.trim(),
),
)
notice = "Ausencia del responsable registrada. Al cerrar el Acta, dejá asentada la ausencia en el circuito de firma."
notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de firmar y cerrar el Acta."
}
}
@@ -484,12 +482,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy {
actClosure = actsRepository.lock(actId)
refreshSelectedActInternal(actId)
notice = "Acta cerrada y pendiente de firma. El contenido y los Hallazgos quedaron inmutables."
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 cerrada y pendiente de firma 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(
@@ -528,8 +526,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun recordCompanyOutcome(status: String, reason: String) {
val actId = selectedAct?.id ?: return
if (status !in setOf("REFUSED", "ABSENT")) {
error = "La manifestación de la empresa debe resolverse con firma, negativa o ausencia documentada."
if (status != "REFUSED") {
error = "La ausencia no resuelve la manifestación de la empresa. Registrá una firma o una negativa."
return
}
if (reason.trim().length < 10) {
@@ -538,11 +536,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
launchBusy {
actClosure = actsRepository.companyOutcome(actId, status, reason)
notice = if (status == "ABSENT") {
"Ausencia para la firma de empresa asentada."
} else {
"Negativa a firmar asentada."
}
notice = "Negativa a firmar asentada."
}
}
@@ -554,7 +548,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false)
clearFindingState()
notice = "${selectedAct?.code ?: "Acta"} firmada, finalizada 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."
}
}
@@ -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(
"Ubicación y foto del Inventario: ${if (options.capture.readyForFinding) "completas" else "pendientes"}",
"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,10 +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 ?: "sin definir"} · ${findingStatusLabel(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,
@@ -229,7 +226,7 @@ fun FieldFindingScreen(model: MainViewModel) {
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Text("Tomar foto con ubicación")
Text("Tomar foto con GPS")
}
}
}
@@ -369,7 +366,7 @@ fun FieldFindingScreen(model: MainViewModel) {
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Text("Tomar foto con ubicación")
Text("Tomar foto con GPS")
}
Text("También podés registrar otro Hallazgo sobre el mismo Inventario.", style = MaterialTheme.typography.bodySmall)
}
@@ -378,17 +375,6 @@ fun FieldFindingScreen(model: MainViewModel) {
}
}
private fun findingStatusLabel(status: String): String = when (status) {
"OPEN" -> "Abierto"
"COMPANY_RESPONDED" -> "Respondido por la empresa"
"UNDER_REVIEW" -> "En revisión"
"VERIFICATION_PENDING" -> "Verificación pendiente"
"READY_TO_CLOSE" -> "Listo para cierre"
"CLOSED" -> "Cerrado"
"CANCELLED" -> "Cancelado"
else -> "Estado no reconocido"
}
private fun findingHasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
@@ -407,7 +393,7 @@ private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = su
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación actual."))
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
@@ -1,32 +0,0 @@
package com.korexlabs.dhinspeccion.ui
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldType
/**
* Compatibilidad de compilación para pantallas heredadas que aún forman parte del árbol Android,
* aunque la navegación productiva use ModernVisitRoot.
*/
internal fun MainViewModel.createFieldAsset(
type: FieldType,
parentId: String?,
familyId: String?,
name: String,
commonName: String?,
attributes: Map<String, Any?>,
latitude: Double,
longitude: Double,
accuracyM: Double?,
) = createFieldAsset(
type = type,
parentId = parentId,
familyId = familyId,
name = name,
commonName = commonName,
description = null,
discoveryNotes = null,
attributes = attributes,
latitude = latitude,
longitude = longitude,
accuracyM = accuracyM,
)
@@ -9,8 +9,8 @@ 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.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
@@ -31,6 +31,7 @@ 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
@@ -102,23 +103,6 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
}
}
@Composable
private fun MendozaBrand() {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Image(
painter = painterResource(R.drawable.logo_mendoza_oficial),
contentDescription = "Gobierno de Mendoza",
modifier = Modifier.size(156.dp),
)
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Dirección de Hidrocarburos · Aplicación de campo")
}
}
@Composable
private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) {
var identifier by rememberSaveable { mutableStateOf("") }
@@ -126,8 +110,15 @@ 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)) {
MendozaBrand()
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) }
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
OutlinedTextField(
@@ -219,9 +210,10 @@ private fun BiometricUnlockScreen(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(R.drawable.logo_mendoza_oficial),
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
contentDescription = "Gobierno de Mendoza",
modifier = Modifier.size(120.dp),
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")
@@ -11,30 +11,79 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Assignment
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.DhRepository
import com.korexlabs.dhinspeccion.data.MobileInspectionOpenRepository
import com.korexlabs.dhinspeccion.data.MobilePlanningAsset
import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.launch
@Composable
fun MobileHomeScreen(model: MainViewModel) {
val session = model.session ?: return
val fieldVisits = model.visits.filter { it.status != "DRAFT" }
val context = LocalContext.current
val scope = rememberCoroutineScope()
val openRepository = remember(context) { MobileInspectionOpenRepository(context) }
var showOpen by rememberSaveable { mutableStateOf(false) }
var areas by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var operators by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var selectedAreaId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedCompanyId by rememberSaveable { mutableStateOf<String?>(null) }
var loadingContext by remember { mutableStateOf(false) }
var opening by remember { mutableStateOf(false) }
var localError by remember { mutableStateOf<String?>(null) }
fun loadAreas() {
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.areas().data }
.onSuccess { areas = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
fun chooseArea(areaId: String) {
selectedAreaId = areaId
selectedCompanyId = null
operators = emptyList()
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.operators(areaId).data }
.onSuccess { operators = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
LaunchedEffect(showOpen) {
if (showOpen && areas.isEmpty()) loadAreas()
}
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
Row(
@@ -42,18 +91,16 @@ fun MobileHomeScreen(model: MainViewModel) {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f)) {
Column {
Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(session.displayName, style = MaterialTheme.typography.bodySmall)
}
Row {
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy) {
Icon(Icons.Filled.Refresh, contentDescription = null)
Spacer(Modifier.width(6.dp))
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy && !opening) {
Text("Actualizar")
}
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = { model.logout() }) { Text("Salir") }
OutlinedButton(onClick = { model.logout() }, enabled = !opening) { Text("Salir") }
}
}
@@ -63,49 +110,97 @@ fun MobileHomeScreen(model: MainViewModel) {
) {
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Surface(
Button(
onClick = {
showOpen = !showOpen
localError = null
},
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.secondaryContainer,
enabled = !opening,
) {
Row(
Modifier.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(Icons.Filled.Assignment, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
Column(Modifier.weight(1f)) {
Text("Trabajo planificado desde oficina", fontWeight = FontWeight.Bold)
Text(if (showOpen) "Cancelar nueva inspección" else "Abrir inspección")
}
if (showOpen) {
Card(Modifier.fillMaxWidth()) {
Column(
Modifier.fillMaxWidth().padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Abrir inspección en campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Acá aparecen las inspecciones ya planificadas y asignadas desde el tablero. La visita se inicia únicamente al llegar al lugar.",
"Elegí el Área y la Operadora vigente. La inspección se crea autoasignada a vos y queda iniciada con la fecha y hora del servidor.",
style = MaterialTheme.typography.bodySmall,
)
Text("1. Área", fontWeight = FontWeight.SemiBold)
if (loadingContext && areas.isEmpty()) CircularProgressIndicator()
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(areas, key = { it.id }) { area ->
AssistChip(
onClick = { chooseArea(area.id) },
label = { Text(if (area.id == selectedAreaId) "${area.name}" else area.name) },
)
}
}
if (selectedAreaId != null) {
Text("2. Operadora", fontWeight = FontWeight.SemiBold)
if (loadingContext && operators.isEmpty()) {
CircularProgressIndicator()
} else if (operators.isEmpty()) {
Text("No hay una Operadora vigente para el Área seleccionada.", color = MaterialTheme.colorScheme.error)
}
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(operators, key = { it.id }) { company ->
AssistChip(
onClick = { selectedCompanyId = company.id },
label = { Text(if (company.id == selectedCompanyId) "${company.name}" else company.name) },
)
}
}
}
Button(
onClick = {
val areaId = selectedAreaId ?: return@Button
val companyId = selectedCompanyId ?: return@Button
scope.launch {
opening = true
localError = null
runCatching { openRepository.open(areaId, companyId) }
.onSuccess { opened ->
showOpen = false
model.openVisit(opened.id)
}
.onFailure { localError = DhRepository.humanError(it) }
opening = false
}
},
modifier = Modifier.fillMaxWidth(),
enabled = selectedAreaId != null && selectedCompanyId != null && !opening && !loadingContext,
) {
Text(if (opening) "Abriendo…" else "Abrir inspección ahora")
}
}
}
}
}
if (model.busy && fieldVisits.isEmpty()) {
if (model.busy && model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
} else if (fieldVisits.isEmpty()) {
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
Icon(Icons.Filled.Assignment, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
Text("No tenés inspecciones listas para campo", fontWeight = FontWeight.Bold)
Text(
"Cuando una inspección quede planificada y asignada desde el tablero, aparecerá en esta pantalla.",
style = MaterialTheme.typography.bodyMedium,
)
Button(onClick = { model.loadVisits() }, enabled = !model.busy) { Text("Actualizar lista") }
}
} else if (model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No tenés inspecciones asignadas. Podés abrir una nueva desde esta tablet.")
}
} else {
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(fieldVisits, key = { it.id }) { visit ->
items(model.visits, key = { it.id }) { visit ->
MobileVisitCard(visit) { model.openVisit(visit.id) }
}
item { Spacer(Modifier.height(24.dp)) }
@@ -116,33 +211,21 @@ fun MobileHomeScreen(model: MainViewModel) {
@Composable
private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
ElevatedCard(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(visit.code, fontWeight = FontWeight.Bold)
StatusPill(mobileVisitStatusLabel(visit.status))
Text(visitStatusLabelEs(visit.status))
}
Text(visit.scopeAsset?.name ?: visit.operationalArea?.name ?: "Alcance sin informar", fontWeight = FontWeight.SemiBold)
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
visit.operationalArea?.name?.takeIf { it != visit.scopeAsset?.name }?.let {
Text("Área · $it", style = MaterialTheme.typography.bodySmall)
}
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
visit.plannedStartAt?.let {
Text("Planificada · ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall)
Text("Prevista: ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall)
}
Text(
"Inventario previsto: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}",
"Inventario: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
internal fun mobileVisitStatusLabel(status: String): String = when (status) {
"DRAFT" -> "Borrador"
"PLANNED" -> "Planificada"
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
else -> "Estado no reconocido"
}
@@ -245,7 +245,7 @@ fun ModernMobileActsScreen(
) {
Text(
if (newActUrgency == "URGENT") {
"El plazo urgente se computará conforme a la política institucional vigente al cerrar el contenido del Acta."
"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,11 +319,7 @@ 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(documentTypeLabel(kind)) },
)
FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(documentTypeLabelEs(kind)) })
}
}
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
@@ -360,7 +356,7 @@ fun ModernMobileActsScreen(
Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text(
"Al cerrar el Acta, su contenido y sus Hallazgos quedan inmutables. El Acta quedará pendiente de firma.",
"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(
@@ -368,13 +364,6 @@ fun ModernMobileActsScreen(
enabled = !model.busy && closure?.responsible != null,
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Acta y dejar pendiente de firma") }
if (closure?.responsible == null) {
Text(
"Antes de cerrar, registrá al responsable de la empresa o su ausencia.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -383,7 +372,7 @@ fun ModernMobileActsScreen(
val signatures = closure?.signatures.orEmpty()
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
val companyResolved = companyOutcome?.status in setOf("SIGNED", "REFUSED", "ABSENT")
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED"
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -392,8 +381,8 @@ fun ModernMobileActsScreen(
Spacer(Modifier.width(8.dp))
Text("Acta pendiente de firma", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text("El contenido ya es inmutable. Completá las firmas o dejá asentada la ausencia/negativa correspondiente para finalizarla.")
closure?.closure?.preparedSha256?.let { Text("SHA-256 de cierre · $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)
@@ -413,29 +402,23 @@ fun ModernMobileActsScreen(
if (companyOutcome != null && companyResolved) {
val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
"REFUSED" -> "Negativa a firmar registrada"
"ABSENT" -> "Ausencia para la firma registrada"
else -> "Manifestación registrada"
"REFUSED" -> "Negativa a firmar"
else -> humanBackendLabelEs(companyOutcome.status)
}
SuccessLine(detail)
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
val savedAbsenceReason = closure.responsible.absenceReason.orEmpty()
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) {
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.secondary)
Column(Modifier.weight(1f)) {
Text("El responsable fue registrado como ausente.", fontWeight = FontWeight.SemiBold)
if (savedAbsenceReason.isNotBlank()) Text(savedAbsenceReason, style = MaterialTheme.typography.bodySmall)
}
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error)
Text(
"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,
)
}
}
OutlinedButton(
onClick = { model.recordCompanyOutcome("ABSENT", savedAbsenceReason) },
enabled = !model.busy && inspectorSigned && savedAbsenceReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Registrar ausencia para la firma") }
} else {
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -479,11 +462,11 @@ fun ModernMobileActsScreen(
) {
Icon(Icons.Filled.CheckCircle, null)
Spacer(Modifier.width(6.dp))
Text("Finalizar Acta")
Text("Firmar y cerrar Acta")
}
} else {
Text(
"La inspección no puede cerrarse hasta resolver la firma, negativa o ausencia de esta Acta.",
"La Inspección no puede cerrarse hasta completar las manifestaciones de esta Acta.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -497,9 +480,9 @@ 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 finalizada e inmutable", fontWeight = FontWeight.Bold)
Text("Al finalizar se genera el PDF del Acta y el archivo INF editable para el Inspector.")
closure?.closure?.finalSha256?.let { Text("SHA-256 final · $it", style = MaterialTheme.typography.bodySmall) }
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) }
}
}
}
@@ -518,12 +501,12 @@ fun ModernMobileActsScreen(
val pendingActs = activeActs.filter { it.status != "SEALED" }
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
Text("Finalizar inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (pendingActs.isEmpty()) {
SuccessLine("Todas las Actas están finalizadas")
SuccessLine("Todas las Actas están firmadas y cerradas")
} else {
Text(
"Actas pendientes de firma o finalización: ${pendingActs.joinToString { it.code }}",
"Falta firmar y cerrar: ${pendingActs.joinToString { it.code }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
@@ -532,7 +515,7 @@ fun ModernMobileActsScreen(
onClick = { model.closeInspection() },
enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(),
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar inspección y salir") }
) { Text("Cerrar Inspección y salir") }
}
}
}
@@ -548,21 +531,7 @@ private fun SuccessLine(text: String) {
}
}
private fun documentTypeLabel(kind: String): String = when (kind) {
"DNI" -> "DNI"
"CUIL" -> "CUIL"
"PASSPORT" -> "Pasaporte"
"OTHER" -> "Otro"
else -> "Otro"
}
private fun modernActStatusLabel(status: String): String = when (status) {
"DRAFT" -> "Borrador"
"LOCKED" -> "Pendiente de firma"
"SEALED" -> "Finalizada"
"CANCELLED" -> "Cancelada"
else -> "Estado no reconocido"
}
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 ||
@@ -262,7 +262,7 @@ private fun ModernChecklistCard(visit: VisitDetail) {
val checklist = visit.checklist
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Antecedentes y controles", fontWeight = FontWeight.Bold)
Text("Antecedentes", fontWeight = FontWeight.Bold)
Text(
"${checklist.antecedents} antecedentes · ${checklist.upcomingControls} próximos controles",
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -297,7 +297,6 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
var name by rememberSaveable(visit.id) { mutableStateOf("") }
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
var description by rememberSaveable(visit.id) { mutableStateOf("") }
var discoveryNotes 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("") }
@@ -314,7 +313,6 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
name = ""
commonName = ""
description = ""
discoveryNotes = ""
selectedTypeId = null
selectedFamilyId = null
familySearch = ""
@@ -431,7 +429,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
pendingPhotoGeo = geo
takePicture.launch(uri)
}
.onFailure { localError = it.message ?: "No se pudo obtener la ubicación para la fotografía." }
.onFailure { localError = it.message ?: "No se pudo obtener el GPS para la fotografía." }
}
}
@@ -449,14 +447,13 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
name = name,
commonName = commonName,
description = description,
discoveryNotes = discoveryNotes,
attributes = buildModernAttributes(type, attributeValues),
latitude = geo.latitude,
longitude = geo.longitude,
accuracyM = geo.accuracyM,
)
}
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación actual." }
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación GPS." }
}
}
@@ -806,15 +803,15 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nombre técnico / identificación *") },
supportingText = { Text("Usá lo que figura en placa o identifica el elemento. El Código DH de campo se genera automáticamente.") },
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,
)
@@ -822,14 +819,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
value = description,
onValueChange = { description = it },
label = { Text("Descripción (opcional)") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
OutlinedTextField(
value = discoveryNotes,
onValueChange = { discoveryNotes = it },
label = { Text("Observaciones del alta (opcional)") },
supportingText = { Text("Anotá datos útiles para la revisión posterior en oficina.") },
supportingText = { Text("Dato equivalente al alta desde el panel web.") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
@@ -851,12 +841,12 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}
HorizontalDivider()
ModernStepHeader(if (isSubinstallation) 3 else 2, "Ubicación y foto")
ModernStepHeader(if (isSubinstallation) 3 else 2, "GPS + foto")
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
Row(Modifier.padding(14.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.CameraAlt, null, tint = MaterialTheme.colorScheme.secondary)
Text(
"Al guardar capturamos la ubicación y abrimos la cámara. La foto es obligatoria antes de registrar Hallazgos.",
"Al guardar capturamos el GPS y abrimos la cámara. La foto es obligatoria antes de registrar Hallazgos.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
@@ -948,7 +938,7 @@ private fun ModernInventoryCard(item: FieldInventoryItem, onInspect: () -> Unit,
}
item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
Text(
if (item.readyForFinding) "Listo para Hallazgos" else "Falta ubicación / foto",
if (item.readyForFinding) "Listo para Hallazgos" else "Falta GPS / foto",
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.SemiBold,
@@ -987,7 +977,7 @@ private fun ModernCaptureCard(
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, "Cerrar") }
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(if (gps) "Ubicación" else "Ubicación pendiente", color = if (gps) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error)
Text(if (gps) "GPS" else "GPS pendiente", color = if (gps) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error)
Text("Fotos · $photos")
}
if (!ready) {
@@ -1087,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 -> "Estado no reconocido"
}
private fun modernStatusLabel(status: String): String = visitStatusLabelEs(status)
private fun buildModernAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition ->
@@ -1133,7 +1117,7 @@ private suspend fun currentModernGeo(context: Context): ModernGeoSnapshot = susp
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación actual."))
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
else continuation.resume(ModernGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
@@ -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() }
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

@@ -6,63 +6,52 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class FinalFieldFlowContractTest {
private val home = File("src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt").readText()
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 viewModel = File("src/main/java/com/korexlabs/dhinspeccion/MainViewModel.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 inspectionIsPlannedInOfficeAndOnlyStartedOnTablet() {
assertTrue(home.contains("Trabajo planificado desde oficina"))
assertFalse(home.contains("MobileInspectionOpenRepository"))
assertFalse(home.contains("Abrir inspección ahora"))
assertTrue(home.contains("fieldVisits = model.visits.filter { it.status != \"DRAFT\" }"))
assertTrue(home.contains("\"DRAFT\" -> \"Borrador\""))
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 findingsAreScopedToInstallationsAndSubinstallations() {
assertTrue(visit.contains("setOf(\"instalacion\", \"subinstalacion\")"))
assertTrue(visit.contains("Buscar existente o cargar Instalación / Subinstalación"))
assertTrue(visit.contains("Guardar y tomar foto"))
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 fieldCreationKeepsDashboardIdentityAndTechnicalRequirements() {
assertTrue(visit.contains("Nombre técnico / identificación *"))
assertTrue(visit.contains("El Código DH de campo se genera automáticamente"))
assertTrue(visit.contains("Clasificación técnica"))
assertTrue(visit.contains("description = description"))
assertTrue(visit.contains("discoveryNotes = discoveryNotes"))
assertTrue(viewModel.contains("description = description?.trim()"))
assertTrue(viewModel.contains("discoveryNotes = discoveryNotes?.trim()"))
fun structuralSelectionUsesTheServerTypeCode() {
assertTrue(visit.contains("type.code.ifBlank { type.typeName ?: type.name }"))
assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"instalacion\", \"subinstalacion\")"))
}
@Test
fun closingActLeavesItPendingForSignatureAndResolvesAbsenceCorrectly() {
assertTrue(acts.contains("Cerrar Acta y dejar pendiente de firma"))
assertTrue(acts.contains("Acta pendiente de firma"))
assertTrue(viewModel.contains("Acta cerrada y pendiente de firma"))
assertTrue(acts.contains("Registrar ausencia para la firma"))
assertTrue(acts.contains("recordCompanyOutcome(\"ABSENT\""))
assertTrue(viewModel.contains("status !in setOf(\"REFUSED\", \"ABSENT\")"))
assertTrue(acts.contains("Finalizar Acta"))
assertTrue(acts.contains("Acta finalizada e inmutable"))
}
@Test
fun activeFieldScreensDoNotExposeKnownEnglishLabels() {
val activeUi = home + visit + acts + findings
assertFalse(activeUi.contains("Text(\"Email\")"))
assertFalse(activeUi.contains("El checklist requiere actualización"))
assertFalse(activeUi.contains("Acta BLOQUEADA"))
assertFalse(activeUi.contains("SELLAR Acta"))
assertFalse(activeUi.contains("Acta SELLADA"))
assertTrue(acts.contains("\"PASSPORT\" -> \"Pasaporte\""))
assertTrue(acts.contains("\"OTHER\" -> \"Otro\""))
assertTrue(findings.contains("findingStatusLabel(finding.status)"))
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\")"))
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ function mountedRepoFile(path: string): string {
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
}
test('Android 0.19.0 targets production API and keeps a distinct installable debug identity', () => {
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 = 28/);
@@ -35,7 +35,7 @@ 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/);
});
test('legacy mobile-open endpoints remain available without becoming the active Android workflow', () => {
test('F6.1 exposes mobile planning context and opens an inspection self-assigned to the current inspector', () => {
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
const dto = source('src/inspection-visits/dto/open-mobile-inspection.dto.ts');
@@ -45,21 +45,23 @@ test('legacy mobile-open endpoints remain available without becoming the active
assert.match(controller, /@RequirePermissions\('inspections\.execute'\)/);
assert.match(controller, /assertMobileInspector\(principal\)/);
assert.match(controller, /leadInspectorUserId: principal\.userId/);
assert.match(controller, /plannedStartAt: new Date\(\)\.toISOString\(\)/);
assert.match(controller, /this\.lifecycle\.plan\(created\.id/);
assert.match(controller, /this\.lifecycle\.start\(created\.id/);
assert.match(dto, /operationalAreaId!: string/);
assert.match(dto, /operatorCompanyId!: string/);
});
test('Android lists inspections assigned from office and starts them only from the visit screen', () => {
test('F6.1 Android lets the inspector choose Area and current Operator and open the inspection now', () => {
const client = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt');
const home = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt');
const visit = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt');
const gate = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt');
assert.match(home, /Trabajo planificado desde oficina/);
assert.match(home, /inspecciones asignadas desde el tablero/);
assert.doesNotMatch(home, /Abrir inspección ahora/);
assert.doesNotMatch(home, /Operadora vigente/);
assert.match(home, /model\.openVisit\(visit\.id\)/);
assert.match(visit, /Iniciar inspección/);
assert.match(visit, /model\.startVisit\(\)/);
assert.match(client, /inspection-visits\/mobile\/planning-context\/areas/);
assert.match(client, /inspection-visits\/mobile\/open/);
assert.match(home, /Abrir inspección/);
assert.match(home, /Abrir inspección ahora/);
assert.match(home, /Operadora vigente/);
assert.match(home, /model\.openVisit\(opened\.id\)/);
assert.match(gate, /else -> MobileHomeScreen\(model\)/);
});
+215
View File
@@ -0,0 +1,215 @@
.asset-compact-form {
gap: 0;
padding: 0;
overflow: hidden;
}
.asset-compact-section {
display: grid;
gap: 14px;
padding: 20px 22px;
border-bottom: 1px solid var(--line);
}
.asset-compact-section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.asset-compact-section-heading h2 {
margin: 0;
font-size: 17px;
}
.asset-compact-primary-grid {
gap: 12px;
}
.asset-compact-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.asset-compact-meta > span {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 10px;
border: 1px solid #e0e5ed;
border-radius: 8px;
background: #f8fafc;
}
.asset-compact-meta small {
color: var(--muted);
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
}
.asset-compact-meta strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--ink);
font-size: 11px;
white-space: nowrap;
}
.asset-compact-context-button {
min-height: 34px;
padding: 7px 11px;
white-space: nowrap;
}
.asset-compact-context-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.asset-compact-context-item {
min-width: 0;
display: grid;
gap: 3px;
padding: 12px 13px;
border: 1px solid #e1e6ee;
border-radius: 10px;
background: #f8fafc;
}
.asset-compact-context-item small {
color: var(--muted);
font-size: 9px;
font-weight: 750;
text-transform: uppercase;
letter-spacing: .05em;
}
.asset-compact-context-item strong {
overflow: hidden;
color: var(--ink);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-compact-context-item span {
overflow: hidden;
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-compact-details {
border-bottom: 1px solid var(--line);
background: #fff;
}
.asset-compact-details > summary {
min-height: 54px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 13px 22px;
cursor: pointer;
list-style: none;
user-select: none;
}
.asset-compact-details > summary::-webkit-details-marker {
display: none;
}
.asset-compact-details > summary:hover {
background: #f8fafc;
}
.asset-compact-details > summary > span:first-child {
min-width: 0;
display: grid;
gap: 2px;
}
.asset-compact-details > summary strong {
color: var(--ink);
font-size: 12px;
}
.asset-compact-details > summary small {
overflow: hidden;
color: var(--muted);
font-size: 10px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-compact-details > summary > span:last-child {
color: #7b8496;
font-size: 16px;
transition: transform .18s ease;
}
.asset-compact-details[open] > summary > span:last-child {
transform: rotate(180deg);
}
.asset-compact-details-body {
display: grid;
gap: 16px;
padding: 2px 22px 20px;
}
.asset-compact-technical {
display: grid;
gap: 12px;
padding-top: 2px;
}
.asset-compact-technical h3 {
margin: 0;
}
.asset-compact-form > .form-actions {
padding: 16px 22px 18px;
background: #fafbfc;
}
@media (max-width: 720px) {
.asset-compact-section {
padding: 17px;
}
.asset-compact-section-heading {
display: grid;
}
.asset-compact-context-button {
width: 100%;
}
.asset-compact-context-grid {
grid-template-columns: 1fr;
}
.asset-compact-details > summary {
padding: 13px 17px;
}
.asset-compact-details-body {
padding: 2px 17px 18px;
}
.asset-compact-form > .form-actions {
padding: 14px 17px 17px;
}
}
+777 -82
View File
@@ -1,11 +1,18 @@
import './AssetEditorPage.css';
import { SearchableSelect } from '../components/SearchableSelect';
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import type { FormEvent, ReactNode } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { ASSET_OPERATIONAL_STATUSES, ASSET_STATUSES, assetOperationalStatusLabel, assetStatusClass, assetStatusLabel } from '../features/assets/assetPresentation';
import {
ASSET_OPERATIONAL_STATUSES,
ASSET_STATUSES,
assetOperationalStatusLabel,
assetStatusClass,
assetStatusLabel,
} from '../features/assets/assetPresentation';
import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel';
import { AssetDossierPanel } from '../features/assets/AssetDossierPanel';
import { AssetMediaPanel } from '../features/assets/AssetMediaPanel';
@@ -15,15 +22,31 @@ import { AssetRegistryPanel } from '../features/assets/AssetRegistryPanel';
import { AssetContextHistoryPanel } from '../features/assets/AssetContextHistoryPanel';
import { AssetFindingCatalogPanel } from '../features/assets/AssetFindingCatalogPanel';
import {
createAsset, getAsset, getAssetLineage, listAssetParentOptions, listAssetTypes, listCompaniesForArea,
listOperationalAreas, updateAsset, updateAssetInformationStatus, updateAssetOperationalStatus,
createAsset,
getAsset,
getAssetLineage,
listAssetParentOptions,
listAssetTypes,
listCompaniesForArea,
listOperationalAreas,
updateAsset,
updateAssetInformationStatus,
updateAssetOperationalStatus,
} from '../lib/api';
import type {
AssetAttributeDefinition, AssetDetail, AssetInformationStatus, AssetOperationalStatus,
AssetLineageItem, AssetListItem, AssetType, OperationalAssetSummary,
AssetAttributeDefinition,
AssetDetail,
AssetInformationStatus,
AssetOperationalStatus,
AssetLineageItem,
AssetListItem,
AssetType,
OperationalAssetSummary,
} from '../lib/api';
const AssetGeometryEditor = lazy(() => import('../features/map/AssetGeometryEditor').then((module) => ({ default: module.AssetGeometryEditor })));
const AssetGeometryEditor = lazy(() =>
import('../features/map/AssetGeometryEditor').then((module) => ({ default: module.AssetGeometryEditor })),
);
type DetailTab = 'summary' | 'dossier' | 'findings' | 'location' | 'registry' | 'files' | 'history';
@@ -35,16 +58,26 @@ function localDateTime(value: unknown): string {
return local.toISOString().slice(0, 16);
}
function normalizeAttributeValues(definitions: AssetAttributeDefinition[], values: Record<string, unknown>): Record<string, unknown> {
function normalizeAttributeValues(
definitions: AssetAttributeDefinition[],
values: Record<string, unknown>,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
definitions.filter((item) => item.isActive).forEach((definition) => {
const raw = values[definition.id];
if (definition.dataType === 'BOOLEAN') result[definition.id] = Boolean(raw);
else if (raw !== undefined && raw !== null && raw !== '') result[definition.id] = definition.dataType === 'NUMBER' ? Number(raw) : raw;
else if (raw !== undefined && raw !== null && raw !== '') {
result[definition.id] = definition.dataType === 'NUMBER' ? Number(raw) : raw;
}
});
return result;
}
function hasAttributeValue(definition: AssetAttributeDefinition, value: unknown): boolean {
if (definition.dataType === 'BOOLEAN') return value === true;
return value !== undefined && value !== null && String(value).trim() !== '';
}
export function AssetEditorPage() {
const { id } = useParams();
const editing = Boolean(id);
@@ -52,7 +85,11 @@ export function AssetEditorPage() {
const [searchParams] = useSearchParams();
const contextParentId = !editing ? searchParams.get('parentId') : null;
const requestedTab = searchParams.get('tab') as DetailTab | null;
const tab: DetailTab = requestedTab && ['summary','dossier','findings','location','registry','files','history'].includes(requestedTab) ? requestedTab : 'summary';
const tab: DetailTab = requestedTab
&& ['summary', 'dossier', 'findings', 'location', 'registry', 'files', 'history'].includes(requestedTab)
? requestedTab
: 'summary';
const { hasPermission } = useAuth();
const canEdit = editing ? hasPermission('assets.update') : hasPermission('assets.create');
const canCreate = hasPermission('assets.create');
@@ -104,10 +141,14 @@ export function AssetEditorPage() {
const [success, setSuccess] = useState('');
const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
const canDirectContextEdit = !editing || Boolean(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT');
const canDirectContextEdit = !editing
|| Boolean(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT');
const selectedType = types.find((type) => type.id === typeId) ?? null;
const definitions = useMemo(() => selectedType?.attributes.filter((item) => item.isActive) ?? [], [selectedType]);
const definitions = useMemo(
() => selectedType?.attributes.filter((item) => item.isActive) ?? [],
[selectedType],
);
useEffect(() => {
Promise.all([
@@ -119,13 +160,26 @@ export function AssetEditorPage() {
setTypes(loadedTypes);
setLineage(loadedLineage);
if (loadedAsset) {
setAsset(loadedAsset); setCode(loadedAsset.code); setName(loadedAsset.name); setCommonName(loadedAsset.commonName ?? ''); setTypeId(loadedAsset.type.id);
setParentId(loadedAsset.parent?.id ?? ''); setOperationalAreaId(loadedAsset.operationalArea?.id ?? '');
setOperatorCompanyId(loadedAsset.operatorCompany?.id ?? ''); setDescription(loadedAsset.description ?? '');
setStatus(loadedAsset.informationStatus); setOperationalStatus(loadedAsset.operationalStatus);
setAttributeValues(Object.fromEntries(loadedAsset.attributes.map((attribute) => [attribute.definitionId, attribute.dataType === 'DATETIME' ? localDateTime(attribute.value) : attribute.value ?? ''])));
setAsset(loadedAsset);
setCode(loadedAsset.code);
setName(loadedAsset.name);
setCommonName(loadedAsset.commonName ?? '');
setTypeId(loadedAsset.type.id);
setParentId(loadedAsset.parent?.id ?? '');
setOperationalAreaId(loadedAsset.operationalArea?.id ?? '');
setOperatorCompanyId(loadedAsset.operatorCompany?.id ?? '');
setDescription(loadedAsset.description ?? '');
setStatus(loadedAsset.informationStatus);
setOperationalStatus(loadedAsset.operationalStatus);
setAttributeValues(Object.fromEntries(
loadedAsset.attributes.map((attribute) => [
attribute.definitionId,
attribute.dataType === 'DATETIME' ? localDateTime(attribute.value) : attribute.value ?? '',
]),
));
} else if (!contextParentId) {
const first = loadedTypes.find((type) => type.isActive && type.canBeRoot) ?? loadedTypes.find((type) => type.isActive);
const first = loadedTypes.find((type) => type.isActive && type.canBeRoot)
?? loadedTypes.find((type) => type.isActive);
if (first) setTypeId(first.id);
}
})
@@ -135,38 +189,56 @@ export function AssetEditorPage() {
useEffect(() => {
if (editing || !contextParentId || types.length === 0) return;
Promise.all([getAsset(contextParentId), getAssetLineage(contextParentId)]).then(([parent, parentLineage]) => {
setContextParent(parent);
setLineage(parentLineage);
const compatible = types.find((type) => type.isActive && type.allowedParentTypes.some((allowed) => allowed.id === parent.type.id));
if (compatible) {
setTypeId(compatible.id); setParentId(parent.id);
const parentType = types.find((type) => type.id === parent.type.id);
if (parentType?.operationalRole === 'AREA') setOperationalAreaId(parent.id);
else if (parent.operationalArea) setOperationalAreaId(parent.operationalArea.id);
}
}).catch((requestError) => setError(errorMessage(requestError)));
Promise.all([getAsset(contextParentId), getAssetLineage(contextParentId)])
.then(([parent, parentLineage]) => {
setContextParent(parent);
setLineage(parentLineage);
const compatible = types.find(
(type) => type.isActive && type.allowedParentTypes.some((allowed) => allowed.id === parent.type.id),
);
if (compatible) {
setTypeId(compatible.id);
setParentId(parent.id);
const parentType = types.find((type) => type.id === parent.type.id);
if (parentType?.operationalRole === 'AREA') setOperationalAreaId(parent.id);
else if (parent.operationalArea) setOperationalAreaId(parent.operationalArea.id);
}
})
.catch((requestError) => setError(errorMessage(requestError)));
}, [editing, contextParentId, types]);
useEffect(() => {
if (!typeId) { setParents([]); return; }
const timer = window.setTimeout(() => listAssetParentOptions(typeId, id, parentSearch).then(setParents).catch((requestError) => setError(errorMessage(requestError))), 220);
if (!typeId) {
setParents([]);
return;
}
const timer = window.setTimeout(
() => listAssetParentOptions(typeId, id, parentSearch)
.then(setParents)
.catch((requestError) => setError(errorMessage(requestError))),
220,
);
return () => window.clearTimeout(timer);
}, [typeId, id, parentSearch]);
useEffect(() => {
if (!canReadRelations || selectedType?.operationalRole !== 'GENERIC' || !parentId) {
setOperationalAreas([]);
if (!parentId) { setOperationalAreaId(''); if (!editing) setOperatorCompanyId(''); }
return;
}
listOperationalAreas(parentId).then((loadedAreas) => {
setOperationalAreas(loadedAreas);
if (operationalAreaId && !loadedAreas.some((area) => area.id === operationalAreaId)) {
if (!parentId) {
setOperationalAreaId('');
if (!editing) setOperatorCompanyId('');
}
}).catch((requestError) => setError(errorMessage(requestError)));
return;
}
listOperationalAreas(parentId)
.then((loadedAreas) => {
setOperationalAreas(loadedAreas);
if (operationalAreaId && !loadedAreas.some((area) => area.id === operationalAreaId)) {
setOperationalAreaId('');
if (!editing) setOperatorCompanyId('');
}
})
.catch((requestError) => setError(errorMessage(requestError)));
}, [canReadRelations, selectedType?.operationalRole, parentId, editing]);
useEffect(() => {
@@ -174,39 +246,85 @@ export function AssetEditorPage() {
setOperationalCompanies([]);
return;
}
listCompaniesForArea(operationalAreaId).then((loadedCompanies) => {
setOperationalCompanies(loadedCompanies);
setOperatorCompanyId((current) => loadedCompanies.some((company) => company.id === current)
? current
: loadedCompanies.length === 1 ? (loadedCompanies[0]?.id ?? '') : '');
}).catch((requestError) => {
setOperationalCompanies([]);
setOperatorCompanyId('');
setError(errorMessage(requestError));
});
listCompaniesForArea(operationalAreaId)
.then((loadedCompanies) => {
setOperationalCompanies(loadedCompanies);
setOperatorCompanyId((current) => loadedCompanies.some((company) => company.id === current)
? current
: loadedCompanies.length === 1 ? (loadedCompanies[0]?.id ?? '') : '');
})
.catch((requestError) => {
setOperationalCompanies([]);
setOperatorCompanyId('');
setError(errorMessage(requestError));
});
}, [editing, canReadRelations, operationalAreaId, selectedType?.operationalRole]);
const changeType = (nextTypeId: string) => { setTypeId(nextTypeId); setParentId(''); setOperationalAreaId(''); if (!editing) setOperatorCompanyId(''); setAttributeValues({}); setParentSearch(''); };
const setAttribute = (definitionId: string, value: unknown) => setAttributeValues((current) => ({ ...current, [definitionId]: value }));
const changeType = (nextTypeId: string) => {
setTypeId(nextTypeId);
setParentId('');
setOperationalAreaId('');
if (!editing) setOperatorCompanyId('');
setAttributeValues({});
setParentSearch('');
};
const setAttribute = (definitionId: string, value: unknown) =>
setAttributeValues((current) => ({ ...current, [definitionId]: value }));
const save = async (event: FormEvent) => {
event.preventDefault(); if (!selectedType) return;
setSaving(true); setError(''); setSuccess('');
event.preventDefault();
if (!selectedType) return;
setSaving(true);
setError('');
setSuccess('');
try {
const attributes = normalizeAttributeValues(definitions, attributeValues);
let saved: AssetDetail;
if (editing && id) {
if (canEdit) saved = await updateAsset(id, { typeId: canDirectContextEdit ? typeId : undefined, code, name, commonName: commonName.trim() || null, parentId: canDirectContextEdit ? parentId || null : undefined, operationalAreaId: canDirectContextEdit ? operationalAreaId || null : undefined, description: description.trim() || null, attributes });
else if (asset) saved = asset; else return;
if (canChangeStatus && saved.informationStatus !== status) saved = await updateAssetInformationStatus(id, status);
if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) saved = await updateAssetOperationalStatus(id, operationalStatus);
setAsset(saved); setSuccess('Registro actualizado correctamente'); setHistoryRefreshKey((current) => current + 1);
if (canEdit) {
saved = await updateAsset(id, {
typeId: canDirectContextEdit ? typeId : undefined,
code,
name,
commonName: commonName.trim() || null,
parentId: canDirectContextEdit ? parentId || null : undefined,
operationalAreaId: canDirectContextEdit ? operationalAreaId || null : undefined,
description: description.trim() || null,
attributes,
});
} else if (asset) saved = asset;
else return;
if (canChangeStatus && saved.informationStatus !== status) {
saved = await updateAssetInformationStatus(id, status);
}
if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) {
saved = await updateAssetOperationalStatus(id, operationalStatus);
}
setAsset(saved);
setSuccess('Registro actualizado correctamente');
setHistoryRefreshKey((current) => current + 1);
} else {
saved = await createAsset({ code, name, commonName: commonName.trim() || null, typeId, parentId: parentId || null, operationalAreaId: operationalAreaId || null, operatorCompanyId: operatorCompanyId || null, description: description.trim() || null, informationStatus: canChangeStatus ? status : 'DRAFT', attributes });
saved = await createAsset({
code,
name,
commonName: commonName.trim() || null,
typeId,
parentId: parentId || null,
operationalAreaId: operationalAreaId || null,
operatorCompanyId: operatorCompanyId || null,
description: description.trim() || null,
informationStatus: canChangeStatus ? status : 'DRAFT',
attributes,
});
navigate(`/inventarios/${saved.id}`, { replace: true });
}
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
if (loading) return <LoadingBlock label="Cargando registro…" />;
@@ -221,44 +339,621 @@ export function AssetEditorPage() {
{ key: 'history', label: 'Historial', show: editing && canReadHistory },
] as const;
const breadcrumbSection = asset?.type.code === 'empresa' || contextParent?.type.code === 'empresa' ? 'companies' : 'territory';
const breadcrumbSection = asset?.type.code === 'empresa' || contextParent?.type.code === 'empresa'
? 'companies'
: 'territory';
const structuralTypeCode = selectedType?.code.trim().toLowerCase() ?? '';
const compactStructuralSummary = editing
&& (structuralTypeCode === 'instalacion' || structuralTypeCode === 'subinstalacion');
const technicalFilledCount = definitions.filter(
(definition) => hasAttributeValue(definition, attributeValues[definition.id]),
).length;
const missingRequiredTechnical = definitions.some(
(definition) => definition.isRequired && !hasAttributeValue(definition, attributeValues[definition.id]),
);
const handleContextChanged = (saved: AssetDetail) => {
setAsset(saved);
setParentId(saved.parent?.id ?? '');
setOperationalAreaId(saved.operationalArea?.id ?? '');
setOperatorCompanyId(saved.operatorCompany?.id ?? '');
setLineage([]);
getAssetLineage(saved.id).then(setLineage).catch(() => undefined);
setHistoryRefreshKey((current) => current + 1);
};
const renderAttributes = (): ReactNode => {
if (definitions.length === 0) {
return <div className="inline-empty">Este tipo no requiere datos técnicos adicionales.</div>;
}
return <div className="dynamic-attributes">
{definitions.map((definition) => {
const value = attributeValues[definition.id];
const label = <span>
{definition.name}{definition.unit ? ` (${definition.unit})` : ''}
{definition.isRequired ? <em>obligatorio</em> : <em>opcional</em>}
</span>;
if (definition.dataType === 'BOOLEAN') {
return <label className="check-row attribute-check" key={definition.id}>
<input
type="checkbox"
checked={Boolean(value)}
onChange={(event) => setAttribute(definition.id, event.target.checked)}
disabled={!canEdit}
/>
<span><strong>{definition.name}</strong><small>{definition.code}</small></span>
</label>;
}
if (definition.dataType === 'SELECT') {
return <label className="field" key={definition.id}>
{label}
<SearchableSelect
value={String(value ?? '')}
onChange={(event) => setAttribute(definition.id, event.target.value)}
disabled={!canEdit}
required={definition.isRequired}
>
<option value="">Seleccionar</option>
{definition.options?.map((option) =>
<option key={option} value={option}>{option}</option>)}
</SearchableSelect>
</label>;
}
const inputType = definition.dataType === 'NUMBER'
? 'number'
: definition.dataType === 'DATE'
? 'date'
: definition.dataType === 'DATETIME'
? 'datetime-local'
: 'text';
return <label className="field" key={definition.id}>
{label}
<input
type={inputType}
value={String(value ?? '')}
onChange={(event) => setAttribute(definition.id, event.target.value)}
disabled={!canEdit}
required={definition.isRequired}
step={definition.dataType === 'NUMBER' ? 'any' : undefined}
maxLength={definition.dataType === 'TEXT' ? 4000 : undefined}
/>
</label>;
})}
</div>;
};
const renderSaveActions = () => canSave && <div className="form-actions">
<Link className="button secondary" to="/inventarios">Cancelar</Link>
<button className="button primary" disabled={saving}>
<Icon name="check" />
{saving ? 'Guardando…' : editing && !canEdit ? 'Actualizar estado' : editing ? 'Guardar cambios' : 'Crear registro'}
</button>
</div>;
const renderCompactStructuralForm = () => <form className="panel form-panel asset-compact-form" onSubmit={save}>
<section className="asset-compact-section">
<div className="asset-compact-section-heading">
<div>
<h2>Datos principales</h2>
<p className="section-copy">Lo que se usa todos los días para reconocer este registro.</p>
</div>
</div>
<div className="form-grid asset-compact-primary-grid">
<label className="field">
<span>Nombre técnico</span>
<input
value={name}
onChange={(event) => setName(event.target.value)}
disabled={!canEdit}
required
maxLength={200}
/>
</label>
<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.: planta vieja, celda principal…"
/>
</label>
</div>
<div className="asset-compact-meta">
<span><small>Código DH</small><strong>{code}</strong></span>
<span><small>Tipo</small><strong>{selectedType?.name ?? 'Sin tipo'}</strong></span>
</div>
</section>
<section className="asset-compact-section">
<div className="asset-compact-section-heading">
<div>
<h2>Ubicación actual</h2>
<p className="section-copy">Dónde está contenido el elemento dentro del Inventario.</p>
</div>
{canReadHistory && id && <Link className="button secondary asset-compact-context-button" to={`/inventarios/${id}?tab=history`}>
<Icon name="edit" />Cambiar / ver historial
</Link>}
</div>
<div className="asset-compact-context-grid">
<div className="asset-compact-context-item">
<small>Registro padre</small>
<strong>{asset?.parent?.name ?? 'Sin padre'}</strong>
{asset?.parent?.code && <span>{asset.parent.code}</span>}
</div>
<div className="asset-compact-context-item">
<small>Área</small>
<strong>{asset?.operationalArea?.name ?? 'Sin área asignada'}</strong>
{asset?.operationalArea?.code && <span>{asset.operationalArea.code}</span>}
</div>
</div>
</section>
{(canChangeStatus || canChangeOperationalStatus) && <details className="asset-compact-details">
<summary>
<span><strong>Estado y opciones</strong><small>Calidad del dato y situación operativa</small></span>
<span aria-hidden="true"></span>
</summary>
<div className="asset-compact-details-body">
<div className="form-grid">
{canChangeStatus && <label className="field">
<span>Estado del dato</span>
<SearchableSelect
value={status}
onChange={(event) => setStatus(event.target.value as AssetInformationStatus)}
disabled={!canEdit && !canChangeStatus}
>
{ASSET_STATUSES.map((item) =>
<option key={item.value} value={item.value}>{item.label}</option>)}
</SearchableSelect>
</label>}
{canChangeOperationalStatus && <label className="field">
<span>Estado operativo</span>
<SearchableSelect
value={operationalStatus}
onChange={(event) => setOperationalStatus(event.target.value as AssetOperationalStatus)}
>
{ASSET_OPERATIONAL_STATUSES.map((item) =>
<option key={item.value} value={item.value}>{item.label}</option>)}
</SearchableSelect>
</label>}
</div>
</div>
</details>}
<details className="asset-compact-details" open={missingRequiredTechnical || canDirectContextEdit}>
<summary>
<span>
<strong>Más datos</strong>
<small>
{description.trim() ? 'Descripción cargada' : 'Sin descripción'}
{' · '}
{technicalFilledCount}/{definitions.length} datos técnicos cargados
</small>
</span>
<span aria-hidden="true"></span>
</summary>
<div className="asset-compact-details-body">
<div className="form-grid">
<label className="field">
<span>Código DH</span>
<input
value={code}
onChange={(event) => setCode(event.target.value.toUpperCase())}
disabled={!canEdit}
required
maxLength={120}
pattern="[A-Z0-9][A-Z0-9._/-]*"
/>
</label>
{canDirectContextEdit && <label className="field">
<span>Tipo de elemento</span>
<SearchableSelect
value={typeId}
onChange={(event) => changeType(event.target.value)}
disabled={!canEdit}
required
>
<option value="">Seleccionar</option>
{types.filter((type) => type.isActive || type.id === typeId).map((type) =>
<option key={type.id} value={type.id}>{type.name}</option>)}
</SearchableSelect>
</label>}
</div>
{canDirectContextEdit && <div className="form-grid">
<div className="field parent-picker">
<span>Registro padre {!selectedType?.canBeRoot && <em>obligatorio</em>}</span>
<input
className="parent-search"
value={parentSearch}
onChange={(event) => setParentSearch(event.target.value)}
disabled={!canEdit}
placeholder="Buscar planta, batería, estación…"
/>
<SearchableSelect
value={parentId}
onChange={(event) => {
setParentId(event.target.value);
setParentSearch('');
}}
disabled={!canEdit}
required={!selectedType?.canBeRoot}
>
<option value="">
{selectedType?.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}
</option>
{parents.map((parent) =>
<option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}
</SearchableSelect>
</div>
{selectedType?.operationalRole === 'GENERIC' && canReadRelations && <label className="field">
<span>Área</span>
<SearchableSelect
value={operationalAreaId}
onChange={(event) => setOperationalAreaId(event.target.value)}
disabled={!canEdit}
>
<option value="">Sin asignación operativa</option>
{operationalAreas.map((area) =>
<option key={area.id} value={area.id}>{area.name}</option>)}
</SearchableSelect>
</label>}
</div>}
<label className="field">
<span>Descripción <em>opcional</em></span>
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
disabled={!canEdit}
maxLength={4000}
rows={2}
/>
</label>
<div className="asset-compact-technical">
<div>
<h3>Datos técnicos</h3>
<p className="section-copy">Sólo completá lo que corresponda para este tipo.</p>
</div>
{renderAttributes()}
</div>
</div>
</details>
{renderSaveActions()}
</form>;
const renderFullForm = () => <form className="panel form-panel" onSubmit={save}>
<div className="form-section">
<div>
<h2>Identificación</h2>
<p className="section-copy">Conservá el nombre técnico y, cuando exista, agregá el nombre habitual usado en campo.</p>
</div>
<div className="form-grid">
<label className="field">
<span>Código DH</span>
<input
value={code}
onChange={(event) => setCode(event.target.value.toUpperCase())}
disabled={!canEdit}
required
maxLength={120}
pattern="[A-Z0-9][A-Z0-9._/-]*"
/>
</label>
<label className="field">
<span>Nombre técnico</span>
<input value={name} onChange={(event) => setName(event.target.value)} disabled={!canEdit} required maxLength={200} />
</label>
<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>
</div>
<label className="field">
<span>Descripción <em>opcional</em></span>
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
disabled={!canEdit}
maxLength={4000}
rows={2}
/>
</label>
</div>
<div className="form-section">
<div>
<h2>Ubicación en la estructura</h2>
<p className="section-copy">
Elegí qué es y dónde está contenido. La ubicación física es independiente de la Operadora del Área.
Los registros ya consolidados cambian de ubicación desde el bloque histórico inferior.
</p>
</div>
<div className="form-grid">
<label className="field">
<span>Tipo de elemento</span>
<SearchableSelect
value={typeId}
onChange={(event) => changeType(event.target.value)}
disabled={(editing && !(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT')) || !canEdit}
required
>
<option value="">Seleccionar</option>
{types.filter((type) => type.isActive || type.id === typeId).map((type) =>
<option key={type.id} value={type.id}>{type.name}</option>)}
</SearchableSelect>
</label>
<div className="field parent-picker">
<span>Registro padre {!selectedType?.canBeRoot && <em>obligatorio</em>}</span>
<input
className="parent-search"
value={parentSearch}
onChange={(event) => setParentSearch(event.target.value)}
disabled={!canEdit || !canDirectContextEdit}
placeholder="Buscar planta, batería, estación…"
/>
<SearchableSelect
value={parentId}
onChange={(event) => {
setParentId(event.target.value);
setParentSearch('');
}}
disabled={!canEdit || !canDirectContextEdit}
required={!selectedType?.canBeRoot}
>
<option value="">
{selectedType?.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}
</option>
{parents.map((parent) =>
<option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}
</SearchableSelect>
<small>Escribí para buscar entre los registros compatibles.</small>
</div>
</div>
<div className="form-grid">
{canChangeStatus && <label className="field">
<span>Estado del dato</span>
<SearchableSelect
value={status}
onChange={(event) => setStatus(event.target.value as AssetInformationStatus)}
disabled={!canEdit && !canChangeStatus}
>
{ASSET_STATUSES.map((item) =>
<option key={item.value} value={item.value}>{item.label}</option>)}
</SearchableSelect>
<small>Calidad y validación del registro.</small>
</label>}
{editing && canChangeOperationalStatus && <label className="field">
<span>Estado operativo</span>
<SearchableSelect
value={operationalStatus}
onChange={(event) => setOperationalStatus(event.target.value as AssetOperationalStatus)}
>
{ASSET_OPERATIONAL_STATUSES.map((item) =>
<option key={item.value} value={item.value}>{item.label}</option>)}
</SearchableSelect>
<small>Situación física u operativa del elemento.</small>
</label>}
</div>
</div>
{selectedType?.operationalRole === 'GENERIC' && canReadRelations && <div className="form-section">
<div>
<h2>{editing ? 'Área física' : 'Área y operadora al alta'}</h2>
<p className="section-copy">
{editing
? 'El Inventario pertenece físicamente al Área. La Operadora vigente se administra en las relaciones temporales del Área y no se reescribe dentro del Inventario.'
: 'Al crear el registro, la Operadora activa del Área queda guardada únicamente como snapshot histórico de alta.'}
</p>
</div>
<div className="form-grid">
<label className="field">
<span>Área</span>
<SearchableSelect
value={operationalAreaId}
onChange={(event) => {
setOperationalAreaId(event.target.value);
if (!editing) setOperatorCompanyId('');
}}
disabled={!canEdit || !canDirectContextEdit}
>
<option value="">Sin asignación operativa</option>
{operationalAreas.map((area) =>
<option key={area.id} value={area.id}>{area.name}</option>)}
</SearchableSelect>
{editing && <small>La Operadora se resuelve por la relación ÁreaEmpresa vigente.</small>}
</label>
{!editing && <label className="field">
<span>Operadora {operationalAreaId && <em>obligatoria</em>}</span>
<SearchableSelect
value={operatorCompanyId}
onChange={(event) => setOperatorCompanyId(event.target.value)}
disabled={!canEdit || !operationalAreaId}
required={Boolean(operationalAreaId)}
>
<option value="">{operationalAreaId ? 'Seleccionar operadora…' : 'Primero seleccioná un área'}</option>
{operationalCompanies.map((company) =>
<option key={company.id} value={company.id}>{company.name}</option>)}
</SearchableSelect>
</label>}
</div>
{!editing && operationalAreaId && operatorCompanyId && <div className="temporal-notice">
<Icon name="check" />
<p><strong>Contexto de alta confirmado.</strong> La Operadora seleccionada se conservará como referencia histórica; futuros cambios se harán en la relación temporal del Área.</p>
</div>}
{editing && asset?.operatorCompany && <div className="temporal-notice">
<Icon name="layers" />
<p><strong>Snapshot histórico de alta:</strong> {asset.operatorCompany.name}. No se modifica desde este registro.</p>
</div>}
</div>}
<div className="form-section">
<div>
<h2>Datos técnicos</h2>
<p className="section-copy">Campos definidos para el tipo seleccionado.</p>
</div>
{renderAttributes()}
</div>
{renderSaveActions()}
</form>;
return <section className="narrow-section asset-detail-page">
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del inventario">
<Link to="/inventarios">Inventarios</Link><span></span>
<Link to={breadcrumbSection === 'companies' ? '/inventarios?section=companies' : '/inventarios?section=territory'}>{breadcrumbSection === 'companies' ? 'Empresas' : 'Áreas y yacimientos'}</Link>
<Link to={breadcrumbSection === 'companies' ? '/inventarios?section=companies' : '/inventarios?section=territory'}>
{breadcrumbSection === 'companies' ? 'Empresas' : 'Áreas y yacimientos'}
</Link>
{lineage.map((item, index) => {
const isLast = index === lineage.length - 1;
const showAsCurrent = editing ? isLast : false;
return <span className="asset-detail-crumb-part" key={item.id}><span></span>{showAsCurrent ? <strong>{item.name}</strong> : <Link to={`/inventarios?section=${breadcrumbSection}&parentId=${item.id}`}>{item.name}</Link>}</span>;
return <span className="asset-detail-crumb-part" key={item.id}>
<span></span>
{showAsCurrent
? <strong>{item.name}</strong>
: <Link to={`/inventarios?section=${breadcrumbSection}&parentId=${item.id}`}>{item.name}</Link>}
</span>;
})}
{!editing && <><span></span><strong>Nuevo registro</strong></>}
</nav>
<div className="page-heading asset-editor-heading"><div><span className="eyebrow">INVENTARIO</span><h1>{editing ? asset?.name ?? 'Registro' : contextParent ? `Agregar en ${contextParent.name}` : 'Nuevo registro'}</h1><p>{editing ? `${asset?.type.name} · ${asset?.code}` : contextParent ? `El sistema heredará el contexto disponible de ${contextParent.code}.` : 'Creá una entidad con identidad, jerarquía y atributos propios.'}</p></div><div className="asset-heading-actions">{editing && asset && <div className="heading-statuses"><span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span><small>{assetOperationalStatusLabel(asset.operationalStatus)}</small></div>}{editing && id && canCreate && <Link className="button primary" to={`/inventarios/nuevo?parentId=${id}`}><Icon name="plus" />Agregar registro aquí</Link>}</div></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{editing && <nav className="asset-detail-tabs">{detailTabs.filter((item) => item.show).map((item) => <Link key={item.key} className={tab === item.key ? 'active' : ''} to={`/inventarios/${id}${item.key === 'summary' ? '' : `?tab=${item.key}`}`}>{item.label}</Link>)}</nav>}
<div className="page-heading asset-editor-heading">
<div>
<span className="eyebrow">INVENTARIO</span>
<h1>{editing ? asset?.name ?? 'Registro' : contextParent ? `Agregar en ${contextParent.name}` : 'Nuevo registro'}</h1>
<p>
{editing
? `${asset?.type.name} · ${asset?.code}`
: contextParent
? `El sistema heredará el contexto disponible de ${contextParent.code}.`
: 'Creá una entidad con identidad, jerarquía y atributos propios.'}
</p>
</div>
<div className="asset-heading-actions">
{editing && asset && <div className="heading-statuses">
<span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>
{assetStatusLabel(asset.informationStatus)}
</span>
<small>{assetOperationalStatusLabel(asset.operationalStatus)}</small>
</div>}
{editing && id && canCreate && <Link className="button primary" to={`/inventarios/nuevo?parentId=${id}`}>
<Icon name="plus" />Agregar registro aquí
</Link>}
</div>
</div>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
{editing && <nav className="asset-detail-tabs">
{detailTabs.filter((item) => item.show).map((item) =>
<Link
key={item.key}
className={tab === item.key ? 'active' : ''}
to={`/inventarios/${id}${item.key === 'summary' ? '' : `?tab=${item.key}`}`}
>
{item.label}
</Link>)}
</nav>}
{(!editing || tab === 'summary') && <>
{contextParent && !editing && <div className="context-create-banner"><Icon name="layers" /><div><strong>Alta contextual</strong><span>{contextParent.name} · {contextParent.code}</span></div><Link to={`/inventarios/${contextParent.id}`}>Ver padre</Link></div>}
<form className="panel form-panel" onSubmit={save}>
<div className="form-section"><div><h2>Identificación</h2><p className="section-copy">Conservá el nombre técnico y, cuando exista, agregá el nombre habitual usado en campo.</p></div><div className="form-grid"><label className="field"><span>Código DH</span><input value={code} onChange={(event) => setCode(event.target.value.toUpperCase())} disabled={!canEdit} required maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" /></label><label className="field"><span>Nombre técnico</span><input value={name} onChange={(event) => setName(event.target.value)} disabled={!canEdit} required maxLength={200} /></label><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></div><label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} disabled={!canEdit} maxLength={4000} rows={2} /></label></div>
{contextParent && !editing && <div className="context-create-banner">
<Icon name="layers" />
<div><strong>Alta contextual</strong><span>{contextParent.name} · {contextParent.code}</span></div>
<Link to={`/inventarios/${contextParent.id}`}>Ver padre</Link>
</div>}
<div className="form-section"><div><h2>Ubicación en la estructura</h2><p className="section-copy">Elegí qué es y dónde está contenido. La ubicación física es independiente de la Operadora del Área. Los registros ya consolidados cambian de ubicación desde el bloque histórico inferior.</p></div><div className="form-grid"><label className="field"><span>Tipo de elemento</span><SearchableSelect value={typeId} onChange={(event) => changeType(event.target.value)} disabled={(editing && !(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT')) || !canEdit} required><option value="">Seleccionar</option>{types.filter((type) => type.isActive || type.id === typeId).map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label><div className="field parent-picker"><span>Registro padre {!selectedType?.canBeRoot && <em>obligatorio</em>}</span><input className="parent-search" value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} disabled={!canEdit || !canDirectContextEdit} placeholder="Buscar planta, batería, estación…" /><SearchableSelect value={parentId} onChange={(event) => { setParentId(event.target.value); setParentSearch(''); }} disabled={!canEdit || !canDirectContextEdit} required={!selectedType?.canBeRoot}><option value="">{selectedType?.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}</option>{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}</SearchableSelect><small>Escribí para buscar entre los registros compatibles.</small></div></div><div className="form-grid">{canChangeStatus && <label className="field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => setStatus(event.target.value as AssetInformationStatus)} disabled={!canEdit && !canChangeStatus}>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect><small>Calidad y validación del registro.</small></label>}{editing && canChangeOperationalStatus && <label className="field"><span>Estado operativo</span><SearchableSelect value={operationalStatus} onChange={(event) => setOperationalStatus(event.target.value as AssetOperationalStatus)}>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect><small>Situación física u operativa del elemento.</small></label>}</div></div>
{compactStructuralSummary ? renderCompactStructuralForm() : renderFullForm()}
{selectedType?.operationalRole === 'GENERIC' && canReadRelations && <div className="form-section"><div><h2>{editing ? 'Área física' : 'Área y operadora al alta'}</h2><p className="section-copy">{editing ? 'El Inventario pertenece físicamente al Área. La Operadora vigente se administra en las relaciones temporales del Área y no se reescribe dentro del Inventario.' : 'Al crear el registro, la Operadora activa del Área queda guardada únicamente como snapshot histórico de alta.'}</p></div><div className="form-grid"><label className="field"><span>Área</span><SearchableSelect value={operationalAreaId} onChange={(event) => { setOperationalAreaId(event.target.value); if (!editing) setOperatorCompanyId(''); }} disabled={!canEdit || !canDirectContextEdit}><option value="">Sin asignación operativa</option>{operationalAreas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}</SearchableSelect>{editing && <small>La Operadora se resuelve por la relación ÁreaEmpresa vigente.</small>}</label>{!editing && <label className="field"><span>Operadora {operationalAreaId && <em>obligatoria</em>}</span><SearchableSelect value={operatorCompanyId} onChange={(event) => setOperatorCompanyId(event.target.value)} disabled={!canEdit || !operationalAreaId} required={Boolean(operationalAreaId)}><option value="">{operationalAreaId ? 'Seleccionar operadora…' : 'Primero seleccioná un área'}</option>{operationalCompanies.map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}</SearchableSelect></label>}</div>{!editing && operationalAreaId && operatorCompanyId && <div className="temporal-notice"><Icon name="check" /><p><strong>Contexto de alta confirmado.</strong> La Operadora seleccionada se conservará como referencia histórica; futuros cambios se harán en la relación temporal del Área.</p></div>}{editing && asset?.operatorCompany && <div className="temporal-notice"><Icon name="layers" /><p><strong>Snapshot histórico de alta:</strong> {asset.operatorCompany.name}. No se modifica desde este registro.</p></div>}</div>}
{!compactStructuralSummary && editing && id && asset && selectedType && canReadHistory
&& <AssetContextHistoryPanel
asset={asset}
type={selectedType}
canManage={canManageContext}
onChanged={handleContextChanged}
/>}
<div className="form-section"><div><h2>Datos técnicos</h2><p className="section-copy">Campos definidos para el tipo seleccionado.</p></div>{definitions.length === 0 ? <div className="inline-empty">Este tipo no requiere datos técnicos adicionales.</div> : <div className="dynamic-attributes">{definitions.map((definition) => { const value = attributeValues[definition.id]; const label = <span>{definition.name}{definition.unit ? ` (${definition.unit})` : ''}{definition.isRequired ? <em>obligatorio</em> : <em>opcional</em>}</span>; if (definition.dataType === 'BOOLEAN') return <label className="check-row attribute-check" key={definition.id}><input type="checkbox" checked={Boolean(value)} onChange={(event) => setAttribute(definition.id, event.target.checked)} disabled={!canEdit} /><span><strong>{definition.name}</strong><small>{definition.code}</small></span></label>; if (definition.dataType === 'SELECT') return <label className="field" key={definition.id}>{label}<SearchableSelect value={String(value ?? '')} onChange={(event) => setAttribute(definition.id, event.target.value)} disabled={!canEdit} required={definition.isRequired}><option value="">Seleccionar</option>{definition.options?.map((option) => <option key={option} value={option}>{option}</option>)}</SearchableSelect></label>; const inputType = definition.dataType === 'NUMBER' ? 'number' : definition.dataType === 'DATE' ? 'date' : definition.dataType === 'DATETIME' ? 'datetime-local' : 'text'; return <label className="field" key={definition.id}>{label}<input type={inputType} value={String(value ?? '')} onChange={(event) => setAttribute(definition.id, event.target.value)} disabled={!canEdit} required={definition.isRequired} step={definition.dataType === 'NUMBER' ? 'any' : undefined} maxLength={definition.dataType === 'TEXT' ? 4000 : undefined} /></label>; })}</div>}</div>
{canSave && <div className="form-actions"><Link className="button secondary" to="/inventarios">Cancelar</Link><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : editing && !canEdit ? 'Actualizar estado' : editing ? 'Guardar cambios' : 'Crear registro'}</button></div>}
</form>
{editing && id && asset && selectedType && canReadHistory && <AssetContextHistoryPanel asset={asset} type={selectedType} canManage={canManageContext} onChanged={(saved) => { setAsset(saved); setParentId(saved.parent?.id ?? ''); setOperationalAreaId(saved.operationalArea?.id ?? ''); setOperatorCompanyId(saved.operatorCompany?.id ?? ''); setLineage([]); getAssetLineage(saved.id).then(setLineage).catch(() => undefined); setHistoryRefreshKey((current) => current + 1); }} />}
{editing && id && asset && canReadRelations && selectedType && selectedType.operationalRole !== 'GENERIC' && <AssetOperationalRelationsPanel assetId={id} role={selectedType.operationalRole} canManage={canManageRelations} />}
{editing && id && asset && canReadRelations && selectedType && selectedType.operationalRole !== 'GENERIC'
&& <AssetOperationalRelationsPanel assetId={id} role={selectedType.operationalRole} canManage={canManageRelations} />}
</>}
{editing && id && asset && tab === 'findings' && canReadFindingCatalog && <AssetFindingCatalogPanel assetId={id} canManage={canManageFindingCatalog} />}
{editing && id && asset && tab === 'dossier' && canReadDossier && <AssetDossierPanel assetId={id} />}
{editing && id && asset && tab === 'location' && <Suspense fallback={<div className="panel"><LoadingBlock label="Cargando ubicación…" /></div>}><AssetGeometryEditor assetId={id} assetName={asset.name} canEdit={canEditGeometry} onChanged={() => setHistoryRefreshKey((current) => current + 1)} /></Suspense>}
{editing && id && asset && tab === 'registry' && <div className="asset-tab-stack">{canReadRegistry && selectedType && <AssetRegistryPanel assetId={id} assetName={asset.name} role={selectedType.operationalRole} canManage={canManageRegistry} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />}{canReadProvenance && <AssetProvenancePanel assetId={id} canManage={canManageProvenance} canVerify={canVerifyProvenance} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />}</div>}
{editing && id && asset && tab === 'files' && canReadMedia && <AssetMediaPanel assetId={id} assetName={asset.name} canManage={canManageMedia} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />}
{editing && id && tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={id} refreshKey={historyRefreshKey} />}
{editing && id && asset && tab === 'findings' && canReadFindingCatalog
&& <AssetFindingCatalogPanel assetId={id} canManage={canManageFindingCatalog} />}
{editing && id && asset && tab === 'dossier' && canReadDossier
&& <AssetDossierPanel assetId={id} />}
{editing && id && asset && tab === 'location'
&& <Suspense fallback={<div className="panel"><LoadingBlock label="Cargando ubicación…" /></div>}>
<AssetGeometryEditor
assetId={id}
assetName={asset.name}
canEdit={canEditGeometry}
onChanged={() => setHistoryRefreshKey((current) => current + 1)}
/>
</Suspense>}
{editing && id && asset && tab === 'registry' && <div className="asset-tab-stack">
{canReadRegistry && selectedType && <AssetRegistryPanel
assetId={id}
assetName={asset.name}
role={selectedType.operationalRole}
canManage={canManageRegistry}
onChanged={() => setHistoryRefreshKey((current) => current + 1)}
/>}
{canReadProvenance && <AssetProvenancePanel
assetId={id}
canManage={canManageProvenance}
canVerify={canVerifyProvenance}
onChanged={() => setHistoryRefreshKey((current) => current + 1)}
/>}
</div>}
{editing && id && asset && tab === 'files' && canReadMedia
&& <AssetMediaPanel
assetId={id}
assetName={asset.name}
canManage={canManageMedia}
onChanged={() => setHistoryRefreshKey((current) => current + 1)}
/>}
{editing && id && tab === 'history' && canReadHistory && <div className="asset-tab-stack">
{compactStructuralSummary && asset && selectedType && <AssetContextHistoryPanel
asset={asset}
type={selectedType}
canManage={canManageContext}
onChanged={handleContextChanged}
/>}
<AssetHistoryPanel assetId={id} refreshKey={historyRefreshKey} />
</div>}
</section>;
}