Compare commits

..
Author SHA1 Message Date
admin 8f4f73f16f test(android): impedir borradores de planificación en campo 2026-09-11 11:54:50 -03:00
admin 89219b1cbe fix(android): mostrar sólo inspecciones listas para campo 2026-09-11 11:52:46 -03:00
admin acdf9c8ccd test(android): fijar ausencia y cierre final del Acta 2026-09-11 11:52:06 -03:00
admin 726768bb78 fix(android): cerrar ausencia de firma con outcome ABSENT 2026-09-11 11:50:35 -03:00
admin 01bd7c8454 fix(android): traducir inspecciones en borrador 2026-09-11 11:48:02 -03:00
admin 6363dcd8cc fix(android): resolver ausencia de firma según contrato F4 2026-09-11 11:47:26 -03:00
admin 007033e48f test(api): fijar planificación web y ejecución móvil asignada 2026-09-11 11:38:31 -03:00
admin 551ae4c44b test(api): actualizar contrato Android 0.19.0 2026-09-11 11:38:19 -03:00
admin 4f0c367d3e docs(android): documentar candidata final de campo 0.19.0 2026-09-11 11:35:30 -03:00
admin ebce71653f test(android): actualizar metadata de 0.19.0 2026-09-11 11:35:03 -03:00
admin 97a4da535a fix(android): mantener compatibilidad de pantallas heredadas al compilar 2026-09-11 11:34:46 -03:00
admin 006f9513d0 test(android): ajustar contrato de navegación de Inventario 2026-09-11 11:32:32 -03:00
admin 7fa2ff03fa test(android): fijar contrato final de campo y castellano 2026-09-11 11:32:10 -03:00
admin 5ebff1cd19 chore(android): versionar candidata final 0.19.0 2026-09-11 11:31:51 -03:00
admin fa20c60a94 fix(android): castellanizar Hallazgos de campo 2026-09-11 11:31:24 -03:00
admin 9ace5b8e46 feat(android): completar alta de Inventario de campo en castellano 2026-09-11 11:30:22 -03:00
admin 752bd4d6db fix(android): alinear alta de campo y cierre pendiente de firma 2026-09-11 11:28:29 -03:00
admin 102b9f4d2f feat(android): aplicar marca Mendoza y castellanizar acceso 2026-09-11 11:27:18 -03:00
admin 10ceedae11 feat(android): incorporar marca oficial Mendoza 2026-09-11 11:26:44 -03:00
admin 0fbb37c153 fix(android): cerrar actas como pendientes de firma y castellanizar flujo 2026-09-11 11:22:12 -03:00
admin 310fd87aeb feat(android): cerrar inicio de campo a inspecciones planificadas 2026-09-11 11:20:55 -03:00
17 changed files with 415 additions and 1384 deletions
-15
View File
@@ -1,15 +0,0 @@
# 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.
+42 -55
View File
@@ -1,8 +1,8 @@
# DH Inspección Android · release final de campo 0.19.0
# DH Inspección Android · release de campo 0.19.0
## Candidata vigente
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
- Fase funcional: **Campo final / Inspección → Acta → Inventario → Hallazgos → Firma**.
- `versionName`: **0.19.0**.
- `versionCode`: **28**.
- Application ID release: `com.korexlabs.dhinspeccion`.
@@ -11,61 +11,48 @@
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 operativo validado
## Procedimiento de campo validado
La APK 0.19.0 fija como recorrido principal de campo:
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.
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.
## Castellano y presentación
La nomenclatura técnica interna de API (`DRAFT`, `LOCKED`, `SEALED`, etc.) no se muestra al inspector: la interfaz usa textos operativos en castellano.
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.
## Alta de Instalaciones y Subinstalaciones
También se incorporó la marca oficial de Mendoza provista para esta release en las pantallas de acceso y desbloqueo biométrico.
El alta móvil queda alineada con el Dashboard y con las validaciones del servidor.
## Integridad del alta de Inventario
### Instalación
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.
- contexto territorial: Yacimiento/Área de la Inspección;
- clasificación técnica compatible;
- **Nombre técnico** obligatorio;
- Nombre habitual opcional;
- Descripción opcional;
- atributos técnicos definidos por el tipo; sólo los marcados como obligatorios bloquean el guardado;
- código DH generado por el sistema cuando corresponde;
- GPS de la tablet;
- fotografía obligatoria antes de crear Hallazgos.
El servidor continúa siendo la autoridad para:
### 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.
- 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.
## Barrera obligatoria
@@ -73,16 +60,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. pruebas unitarias Android reales, con verificación de que exista al menos una prueba ejecutada;
3. unit tests Android reales, incluyendo contratos del procedimiento final;
4. `assembleDebug`;
5. `assembleRelease` para comprobar que la variante productiva compile;
6. empaquetado del APK debug con SHA-256 y metadata de commit/versionado.
Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de promover cambios que afecten al servidor.
Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de incorporar la release a `main`.
## Firma release
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.
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`.
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.
@@ -91,7 +78,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 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;
- 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;
- 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 elaboración. Cerrala o cancelala antes de crear la siguiente."
error = "Ya existe un Acta en borrador. Cerrala 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,15 +237,16 @@ 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) {
@@ -260,6 +261,7 @@ 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,
@@ -267,7 +269,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
deviceCapturedAt = Instant.now().toString(),
)
selectedFieldAsset = repository.createFieldAsset(visitId, request)
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
notice = "Inventario creado con ubicación. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
reloadCurrentInventory(visitId)
}
}
@@ -321,7 +323,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
)
selectedFieldAsset = selectedFieldAsset?.copy(capture = response.capture)
notice = if (response.capture.readyForFinding) {
"Captura completa: GPS y fotografía registrados."
"Captura completa: ubicación y fotografía registradas."
} else {
"Fotografía registrada."
}
@@ -342,7 +344,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 elaboración antes de registrar Hallazgos."
error = "Creá o seleccioná el Acta en borrador antes de registrar Hallazgos."
return
}
launchBusy {
@@ -364,7 +366,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 elaboración seleccionada."
error = "No hay un Acta en borrador seleccionada."
return
}
if (description.isBlank()) {
@@ -421,7 +423,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
description = description,
)
loadEvidenceInternal(findingId)
notice = "Evidencia fotográfica registrada con GPS."
notice = "Evidencia fotográfica registrada con ubicación."
}
}
@@ -473,7 +475,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
absenceReason = reason.trim(),
),
)
notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de firmar y cerrar el Acta."
notice = "Ausencia del responsable registrada. Al cerrar el Acta, dejá asentada la ausencia en el circuito de firma."
}
}
@@ -482,12 +484,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy {
actClosure = actsRepository.lock(actId)
refreshSelectedActInternal(actId)
notice = "Acta cerrada y pendiente de firma. Su contenido quedó inmutable; las firmas y manifestaciones pueden completarse a continuación."
notice = "Acta cerrada y pendiente de firma. El contenido y los Hallazgos quedaron inmutables."
}
}
fun reopenSelectedAct() {
error = "Un Acta pendiente de firma es inmutable y no puede volver a edición."
error = "Un Acta cerrada y pendiente de firma es inmutable y no puede volver a borrador."
}
fun signSelectedActAsInspector(
@@ -526,8 +528,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun recordCompanyOutcome(status: String, reason: String) {
val actId = selectedAct?.id ?: return
if (status != "REFUSED") {
error = "La ausencia no resuelve la manifestación de la empresa. Registrá una firma o una negativa."
if (status !in setOf("REFUSED", "ABSENT")) {
error = "La manifestación de la empresa debe resolverse con firma, negativa o ausencia documentada."
return
}
if (reason.trim().length < 10) {
@@ -536,7 +538,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
launchBusy {
actClosure = actsRepository.companyOutcome(actId, status, reason)
notice = "Negativa a firmar asentada."
notice = if (status == "ABSENT") {
"Ausencia para la firma de empresa asentada."
} else {
"Negativa a firmar asentada."
}
}
}
@@ -548,7 +554,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false)
clearFindingState()
notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
notice = "${selectedAct?.code ?: "Acta"} firmada, finalizada e inmutable. 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(
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "completo" else "pendiente"}",
"Ubicación y foto del Inventario: ${if (options.capture.readyForFinding) "completas" else "pendientes"}",
color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
@@ -210,7 +210,10 @@ fun FieldFindingScreen(model: MainViewModel) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold)
Text("Gravedad: ${finding.severity ?: "s/d"} · ${findingStatusLabelEs(finding.status)}", style = MaterialTheme.typography.bodySmall)
Text(
"Gravedad: ${finding.severity ?: "sin definir"} · ${findingStatusLabel(finding.status)}",
style = MaterialTheme.typography.bodySmall,
)
Text(
"Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}",
style = MaterialTheme.typography.bodySmall,
@@ -226,7 +229,7 @@ fun FieldFindingScreen(model: MainViewModel) {
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Text("Tomar foto con GPS")
Text("Tomar foto con ubicación")
}
}
}
@@ -366,7 +369,7 @@ fun FieldFindingScreen(model: MainViewModel) {
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Text("Tomar foto con GPS")
Text("Tomar foto con ubicación")
}
Text("También podés registrar otro Hallazgo sobre el mismo Inventario.", style = MaterialTheme.typography.bodySmall)
}
@@ -375,6 +378,17 @@ 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
@@ -393,7 +407,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 GPS actual."))
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación actual."))
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
@@ -0,0 +1,32 @@
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,7 +31,6 @@ 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
@@ -103,6 +102,23 @@ 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("") }
@@ -110,15 +126,8 @@ 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), 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")
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
MendozaBrand()
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
OutlinedTextField(
@@ -210,10 +219,9 @@ private fun BiometricUnlockScreen(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
painter = painterResource(R.drawable.logo_mendoza_oficial),
contentDescription = "Gobierno de Mendoza",
modifier = Modifier.fillMaxWidth().height(140.dp),
contentScale = ContentScale.Fit,
modifier = Modifier.size(120.dp),
)
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Ingresá con tu huella")
@@ -11,79 +11,30 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AssistChip
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Assignment
import androidx.compose.material.icons.filled.Refresh
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 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()
}
val fieldVisits = model.visits.filter { it.status != "DRAFT" }
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
Row(
@@ -91,16 +42,18 @@ fun MobileHomeScreen(model: MainViewModel) {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Column(Modifier.weight(1f)) {
Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(session.displayName, style = MaterialTheme.typography.bodySmall)
}
Row {
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy && !opening) {
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy) {
Icon(Icons.Filled.Refresh, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text("Actualizar")
}
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = { model.logout() }, enabled = !opening) { Text("Salir") }
OutlinedButton(onClick = { model.logout() }) { Text("Salir") }
}
}
@@ -110,97 +63,49 @@ 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) }
Button(
onClick = {
showOpen = !showOpen
localError = null
},
Surface(
modifier = Modifier.fillMaxWidth(),
enabled = !opening,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.secondaryContainer,
) {
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)
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(
"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.",
"Acá aparecen las inspecciones ya planificadas y asignadas desde el tablero. La visita se inicia únicamente al llegar al lugar.",
style = MaterialTheme.typography.bodySmall,
)
Text("1. Área", fontWeight = FontWeight.SemiBold)
if (loadingContext && areas.isEmpty()) CircularProgressIndicator()
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(areas, key = { it.id }) { area ->
AssistChip(
onClick = { chooseArea(area.id) },
label = { Text(if (area.id == selectedAreaId) "${area.name}" else area.name) },
)
}
}
if (selectedAreaId != null) {
Text("2. Operadora", fontWeight = FontWeight.SemiBold)
if (loadingContext && operators.isEmpty()) {
CircularProgressIndicator()
} else if (operators.isEmpty()) {
Text("No hay una Operadora vigente para el Área seleccionada.", color = MaterialTheme.colorScheme.error)
}
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(operators, key = { it.id }) { company ->
AssistChip(
onClick = { selectedCompanyId = company.id },
label = { Text(if (company.id == selectedCompanyId) "${company.name}" else company.name) },
)
}
}
}
Button(
onClick = {
val areaId = selectedAreaId ?: return@Button
val companyId = selectedCompanyId ?: return@Button
scope.launch {
opening = true
localError = null
runCatching { openRepository.open(areaId, companyId) }
.onSuccess { opened ->
showOpen = false
model.openVisit(opened.id)
}
.onFailure { localError = DhRepository.humanError(it) }
opening = false
}
},
modifier = Modifier.fillMaxWidth(),
enabled = selectedAreaId != null && selectedCompanyId != null && !opening && !loadingContext,
) {
Text(if (opening) "Abriendo…" else "Abrir inspección ahora")
}
}
}
}
}
if (model.busy && model.visits.isEmpty()) {
if (model.busy && fieldVisits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
} else if (model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No tenés inspecciones asignadas. Podés abrir una nueva desde esta tablet.")
} else 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 {
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(model.visits, key = { it.id }) { visit ->
items(fieldVisits, key = { it.id }) { visit ->
MobileVisitCard(visit) { model.openVisit(visit.id) }
}
item { Spacer(Modifier.height(24.dp)) }
@@ -211,21 +116,33 @@ fun MobileHomeScreen(model: MainViewModel) {
@Composable
private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
ElevatedCard(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(visit.code, fontWeight = FontWeight.Bold)
Text(visitStatusLabelEs(visit.status))
StatusPill(mobileVisitStatusLabel(visit.status))
}
Text(visit.scopeAsset?.name ?: visit.operationalArea?.name ?: "Alcance sin informar", fontWeight = FontWeight.SemiBold)
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
visit.operationalArea?.name?.takeIf { it != visit.scopeAsset?.name }?.let {
Text("Área · $it", style = MaterialTheme.typography.bodySmall)
}
visit.plannedStartAt?.let {
Text("Prevista: ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall)
Text("Planificada · ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall)
}
Text(
"Inventario: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}",
"Inventario previsto: ${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á desde el Acta conforme a la política institucional vigente al cerrar su contenido."
"El plazo urgente se computará conforme a la política institucional vigente al cerrar el contenido del Acta."
} else {
"El plazo no urgente comienza con la oficialización GEDO, no al crear el Acta."
},
@@ -319,7 +319,11 @@ 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(documentTypeLabelEs(kind)) })
FilterChip(
selected = documentType == kind,
onClick = { documentType = kind },
label = { Text(documentTypeLabel(kind)) },
)
}
}
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
@@ -356,7 +360,7 @@ fun ModernMobileActsScreen(
Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text(
"Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. Podés firmarla ahora o continuar con otra Acta.",
"Al cerrar el Acta, su contenido y sus Hallazgos quedan inmutables. El Acta quedará pendiente de firma.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
@@ -364,6 +368,13 @@ 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,
)
}
}
}
}
@@ -372,7 +383,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 == "SIGNED" || companyOutcome?.status == "REFUSED"
val companyResolved = companyOutcome?.status in setOf("SIGNED", "REFUSED", "ABSENT")
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -381,8 +392,8 @@ fun ModernMobileActsScreen(
Spacer(Modifier.width(8.dp))
Text("Acta pendiente de firma", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
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) }
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) }
HorizontalDivider()
Text("Firma del inspector", fontWeight = FontWeight.Bold)
@@ -402,23 +413,29 @@ 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"
else -> humanBackendLabelEs(companyOutcome.status)
"REFUSED" -> "Negativa a firmar registrada"
"ABSENT" -> "Ausencia para la firma registrada"
else -> "Manifestación registrada"
}
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") {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) {
val savedAbsenceReason = closure.responsible.absenceReason.orEmpty()
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error)
Text(
"La ausencia no resuelve la manifestación. Debe registrarse firma o negativa antes de firmar y cerrar el Acta.",
Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
)
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)
}
}
}
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)) {
@@ -462,11 +479,11 @@ fun ModernMobileActsScreen(
) {
Icon(Icons.Filled.CheckCircle, null)
Spacer(Modifier.width(6.dp))
Text("Firmar y cerrar Acta")
Text("Finalizar Acta")
}
} else {
Text(
"La Inspección no puede cerrarse hasta completar las manifestaciones de esta Acta.",
"La inspección no puede cerrarse hasta resolver la firma, negativa o ausencia de esta Acta.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -480,9 +497,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 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) }
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) }
}
}
}
@@ -501,12 +518,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 firmadas y cerradas")
SuccessLine("Todas las Actas están finalizadas")
} else {
Text(
"Falta firmar y cerrar: ${pendingActs.joinToString { it.code }}",
"Actas pendientes de firma o finalización: ${pendingActs.joinToString { it.code }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
@@ -515,7 +532,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") }
}
}
}
@@ -531,7 +548,21 @@ private fun SuccessLine(text: String) {
}
}
private fun modernActStatusLabel(status: String): String = actStatusLabelEs(status)
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 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", fontWeight = FontWeight.Bold)
Text("Antecedentes y controles", fontWeight = FontWeight.Bold)
Text(
"${checklist.antecedents} antecedentes · ${checklist.upcomingControls} próximos controles",
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -297,6 +297,7 @@ 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("") }
@@ -313,6 +314,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
name = ""
commonName = ""
description = ""
discoveryNotes = ""
selectedTypeId = null
selectedFamilyId = null
familySearch = ""
@@ -429,7 +431,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
pendingPhotoGeo = geo
takePicture.launch(uri)
}
.onFailure { localError = it.message ?: "No se pudo obtener el GPS para la fotografía." }
.onFailure { localError = it.message ?: "No se pudo obtener la ubicación para la fotografía." }
}
}
@@ -447,13 +449,14 @@ 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 GPS." }
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación actual." }
}
}
@@ -803,15 +806,15 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nombre técnico *") },
supportingText = { Text("Usá la denominación técnica del elemento. El código DH se genera automáticamente.") },
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.") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = commonName,
onValueChange = { commonName = it },
label = { Text("Nombre habitual (opcional)") },
label = { Text("Nombre habitual") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
@@ -819,7 +822,14 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
value = description,
onValueChange = { description = it },
label = { Text("Descripción (opcional)") },
supportingText = { Text("Dato equivalente al alta desde el panel web.") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
OutlinedTextField(
value = discoveryNotes,
onValueChange = { discoveryNotes = it },
label = { Text("Observaciones del alta (opcional)") },
supportingText = { Text("Anotá datos útiles para la revisión posterior en oficina.") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
@@ -841,12 +851,12 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}
HorizontalDivider()
ModernStepHeader(if (isSubinstallation) 3 else 2, "GPS + foto")
ModernStepHeader(if (isSubinstallation) 3 else 2, "Ubicación y 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 el GPS y abrimos la cámara. La foto es obligatoria antes de registrar Hallazgos.",
"Al guardar capturamos la ubicación y abrimos la cámara. La foto es obligatoria antes de registrar Hallazgos.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
@@ -938,7 +948,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 GPS / foto",
if (item.readyForFinding) "Listo para Hallazgos" else "Falta ubicación / foto",
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.SemiBold,
@@ -977,7 +987,7 @@ private fun ModernCaptureCard(
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, "Cerrar") }
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(if (gps) "GPS" else "GPS pendiente", color = if (gps) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error)
Text(if (gps) "Ubicación" else "Ubicación pendiente", color = if (gps) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error)
Text("Fotos · $photos")
}
if (!ready) {
@@ -1077,14 +1087,20 @@ private fun ModernStepHeader(step: Int, title: String) {
private fun modernItemTypeCode(item: FieldInventoryItem): String {
val type = item.type ?: return ""
return modernNormalize(type.code.ifBlank { type.typeName ?: type.name })
return modernNormalize(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 = visitStatusLabelEs(status)
private fun modernStatusLabel(status: String): String = when (status) {
"PLANNED" -> "Planificada"
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
else -> "Estado no reconocido"
}
private fun buildModernAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition ->
@@ -1117,7 +1133,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 GPS actual."))
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación 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 a la huella de integridad del Acta.",
"Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora al hash del Acta.",
style = MaterialTheme.typography.bodySmall,
)
Canvas(
@@ -1,50 +0,0 @@
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.

After

Width:  |  Height:  |  Size: 6.6 KiB

@@ -6,52 +6,63 @@ 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 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()
private val viewModel = File("src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt").readText()
@Test
fun inspectionActFindingFlowMatchesFieldProcedure() {
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\""))
assertTrue(visit.contains("Iniciar inspección"))
assertTrue(acts.contains("Crear nueva Acta"))
assertTrue(acts.contains("Agregar Hallazgo"))
assertTrue(visit.contains("Instalación"))
assertTrue(visit.contains("Subinstalación"))
assertTrue(visit.contains("Guardar y tomar foto"))
assertTrue(acts.contains("Cerrar Acta y dejar pendiente de firma"))
assertTrue(labels.contains("\"LOCKED\" -> \"Pendiente de firma\""))
}
@Test
fun fieldCreateKeepsDashboardEquivalentMinimumAndCaptureBarrier() {
assertTrue(visit.contains("Nombre técnico *"))
assertTrue(visit.contains("Nombre habitual (opcional)"))
assertTrue(visit.contains("Descripción (opcional)"))
assertTrue(visit.contains("familyReady"))
assertTrue(visit.contains("attributesReady"))
assertTrue(visit.contains("GPS + foto"))
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"))
assertTrue(visit.contains("La foto es obligatoria antes de registrar Hallazgos"))
}
@Test
fun structuralSelectionUsesTheServerTypeCode() {
assertTrue(visit.contains("type.code.ifBlank { type.typeName ?: type.name }"))
assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"instalacion\", \"subinstalacion\")"))
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()"))
}
@Test
fun currentFieldUiDoesNotExposeBackendEnglishStates() {
assertTrue(home.contains("visitStatusLabelEs(visit.status)"))
assertTrue(findings.contains("findingStatusLabelEs(finding.status)"))
assertTrue(acts.contains("documentTypeLabelEs(kind)"))
assertTrue(labels.contains("\"PASSPORT\" -> \"Pasaporte\""))
assertTrue(labels.contains("\"OTHER\" -> \"Otro\""))
assertFalse(acts.contains("Finalizar y BLOQUEAR Acta"))
assertFalse(acts.contains("Acta BLOQUEADA"))
assertFalse(acts.contains("SELLAR Acta"))
assertFalse(acts.contains("Acta SELLADA"))
assertFalse(acts.contains("Text(\"Email\")"))
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)"))
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ function mountedRepoFile(path: string): string {
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
}
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
test('Android 0.19.0 targets production API and keeps a distinct installable debug identity', () => {
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('F6.1 exposes mobile planning context and opens an inspection self-assigned to the current inspector', () => {
test('legacy mobile-open endpoints remain available without becoming the active Android workflow', () => {
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
const dto = source('src/inspection-visits/dto/open-mobile-inspection.dto.ts');
@@ -45,23 +45,21 @@ test('F6.1 exposes mobile planning context and opens an inspection self-assigned
assert.match(controller, /@RequirePermissions\('inspections\.execute'\)/);
assert.match(controller, /assertMobileInspector\(principal\)/);
assert.match(controller, /leadInspectorUserId: principal\.userId/);
assert.match(controller, /plannedStartAt: new Date\(\)\.toISOString\(\)/);
assert.match(controller, /this\.lifecycle\.plan\(created\.id/);
assert.match(controller, /this\.lifecycle\.start\(created\.id/);
assert.match(dto, /operationalAreaId!: string/);
assert.match(dto, /operatorCompanyId!: string/);
});
test('F6.1 Android lets the inspector choose Area and current Operator and open the inspection now', () => {
const client = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt');
test('Android lists inspections assigned from office and starts them only from the visit screen', () => {
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(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(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(gate, /else -> MobileHomeScreen\(model\)/);
});
-215
View File
@@ -1,215 +0,0 @@
.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;
}
}
+82 -791
View File
@@ -1,18 +1,11 @@
import './AssetEditorPage.css';
import { SearchableSelect } from '../components/SearchableSelect';
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import type { FormEvent, ReactNode } from 'react';
import type { FormEvent } 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';
@@ -22,31 +15,15 @@ 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';
@@ -58,34 +35,16 @@ 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() !== '';
}
function normalizeStructuralType(value: string | null | undefined): string {
return (value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase();
}
export function AssetEditorPage() {
const { id } = useParams();
const editing = Boolean(id);
@@ -93,11 +52,7 @@ 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');
@@ -149,14 +104,10 @@ 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([
@@ -168,26 +119,13 @@ 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);
}
})
@@ -197,56 +135,38 @@ 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) {
if (!parentId) { setOperationalAreaId(''); if (!editing) setOperatorCompanyId(''); }
return;
}
listOperationalAreas(parentId).then((loadedAreas) => {
setOperationalAreas(loadedAreas);
if (operationalAreaId && !loadedAreas.some((area) => area.id === operationalAreaId)) {
setOperationalAreaId('');
if (!editing) setOperatorCompanyId('');
}
return;
}
listOperationalAreas(parentId)
.then((loadedAreas) => {
setOperationalAreas(loadedAreas);
if (operationalAreaId && !loadedAreas.some((area) => area.id === operationalAreaId)) {
setOperationalAreaId('');
if (!editing) setOperatorCompanyId('');
}
})
.catch((requestError) => setError(errorMessage(requestError)));
}).catch((requestError) => setError(errorMessage(requestError)));
}, [canReadRelations, selectedType?.operationalRole, parentId, editing]);
useEffect(() => {
@@ -254,85 +174,39 @@ 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…" />;
@@ -347,627 +221,44 @@ 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 structuralTypeCode = normalizeStructuralType(selectedType?.code);
const structuralTypeName = normalizeStructuralType(selectedType?.name);
const compactStructuralSummary = editing && (
['instalacion', 'instalacion-superficie', 'instalacion_de_superficie', 'instalacion-de-superficie', 'subinstalacion']
.includes(structuralTypeCode)
|| structuralTypeName === 'instalacion'
|| structuralTypeName === 'instalacion de superficie'
|| structuralTypeName === '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>;
const breadcrumbSection = asset?.type.code === 'empresa' || contextParent?.type.code === 'empresa' ? 'companies' : 'territory';
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>}
<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 && <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>}
{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>
{compactStructuralSummary ? renderCompactStructuralForm() : renderFullForm()}
<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 && editing && id && asset && selectedType && canReadHistory
&& <AssetContextHistoryPanel
asset={asset}
type={selectedType}
canManage={canManageContext}
onChanged={handleContextChanged}
/>}
{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>}
{editing && id && asset && canReadRelations && selectedType && selectedType.operationalRole !== 'GENERIC'
&& <AssetOperationalRelationsPanel assetId={id} role={selectedType.operationalRole} canManage={canManageRelations} />}
<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 && 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>}
{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} />}
</section>;
}