Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f4f73f16f | ||
|
|
89219b1cbe | ||
|
|
acdf9c8ccd | ||
|
|
726768bb78 | ||
|
|
01bd7c8454 | ||
|
|
6363dcd8cc | ||
|
|
007033e48f | ||
|
|
551ae4c44b | ||
|
|
4f0c367d3e | ||
|
|
ebce71653f | ||
|
|
97a4da535a | ||
|
|
006f9513d0 | ||
|
|
7fa2ff03fa | ||
|
|
5ebff1cd19 | ||
|
|
fa20c60a94 | ||
|
|
9ace5b8e46 | ||
|
|
752bd4d6db | ||
|
|
102b9f4d2f | ||
|
|
10ceedae11 | ||
|
|
0fbb37c153 | ||
|
|
310fd87aeb | ||
|
|
80ffb35065 | ||
|
|
6f79abbcff | ||
|
|
d18375b85b | ||
|
|
85a84e0f63 | ||
|
|
03f42209b9 | ||
|
|
74ff64182c | ||
|
|
a9e300d6a4 | ||
|
|
143db53552 | ||
|
|
d9fbd95da3 | ||
|
|
b744a77f49 | ||
|
|
082354320b | ||
|
|
71dda05c20 | ||
|
|
b88855860d | ||
|
|
ec84f42a74 | ||
|
|
b859278ad0 | ||
|
|
32fc5bc60d |
+41
-23
@@ -1,40 +1,58 @@
|
||||
# DH Inspección Android · release Campo Moderno 0.18.0
|
||||
# DH Inspección Android · release de campo 0.19.0
|
||||
|
||||
## Candidata vigente
|
||||
|
||||
- Fase funcional: **Campo Moderno / UX dinámica de Inventario y Actas**.
|
||||
- `versionName`: **0.18.0**.
|
||||
- `versionCode`: **27**.
|
||||
- Fase funcional: **Campo final / Inspección → Acta → Inventario → Hallazgos → Firma**.
|
||||
- `versionName`: **0.19.0**.
|
||||
- `versionCode`: **28**.
|
||||
- Application ID release: `com.korexlabs.dhinspeccion`.
|
||||
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
|
||||
- API: `https://dhv2.korexlabs.com/api/v3/`.
|
||||
|
||||
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
|
||||
|
||||
## Novedades de campo 0.18.0
|
||||
## Procedimiento de campo validado
|
||||
|
||||
La APK adopta una capa visual Material 3 propia de DH: superficies más limpias, jerarquía visual más clara, botones y estados más legibles, tarjetas con menor ruido y una navegación consistente entre Inspección, Actas e Inventario.
|
||||
1. La inspección se **planifica y asigna desde el tablero web**. La APK no crea inspecciones nuevas.
|
||||
2. En la tablet el inspector abre una inspección asignada y, al llegar al lugar, pulsa **Iniciar inspección**.
|
||||
3. Con la inspección en curso se crea o selecciona el **Acta** de trabajo. Sólo puede existir un Acta en borrador a la vez; una inspección puede contener varias Actas sucesivas.
|
||||
4. Los **Hallazgos** se registran exclusivamente sobre **Instalaciones o Subinstalaciones**.
|
||||
5. Si el elemento ya existe, se busca y selecciona desde Inventario de campo.
|
||||
6. Si no existe, se da de alta desde la APK siguiendo la misma estructura que el tablero:
|
||||
- Instalación dentro del Yacimiento de la inspección;
|
||||
- Subinstalación dentro de una Instalación seleccionada;
|
||||
- nombre técnico / identificación obligatorio;
|
||||
- Código DH de campo autogenerado por el servidor;
|
||||
- nombre habitual opcional;
|
||||
- descripción y observaciones de alta opcionales;
|
||||
- clasificación técnica obligatoria cuando el tipo la requiera;
|
||||
- atributos técnicos dinámicos obligatorios según la configuración del tipo;
|
||||
- Área y Operadora heredadas de la inspección planificada;
|
||||
- ubicación del dispositivo y fotografía obligatorias antes de habilitar Hallazgos.
|
||||
7. Sobre el elemento se selecciona un Hallazgo del catálogo contextual o **OTROS** cuando corresponda; se registra descripción, gravedad y evidencia fotográfica si aplica.
|
||||
8. Al terminar el contenido se pulsa **Cerrar Acta y dejar pendiente de firma**. El contenido y los Hallazgos quedan inmutables y el estado visible pasa a **Pendiente de firma**.
|
||||
9. Se registran las firmas y la manifestación de la empresa. La ausencia por sí sola no modifica el contenido cerrado: el Acta permanece pendiente hasta resolver el circuito de firma/manifestación conforme a la regla vigente.
|
||||
10. Una vez resuelta la firma, el Acta queda **Firmada e inmutable**. La inspección sólo puede cerrarse cuando todas sus Actas no canceladas estén finalizadas.
|
||||
|
||||
La carga de una Subinstalación mantiene la integridad del modelo físico pero reduce pasos:
|
||||
## Castellano y presentación
|
||||
|
||||
1. el buscador de **Instalaciones padre** ahora consulta mientras el inspector escribe;
|
||||
2. cada coincidencia aparece como tarjeta seleccionable y tocarla avanza directamente a la identificación de la Subinstalación;
|
||||
3. la clasificación técnica continúa filtrada por compatibilidad con la Instalación elegida;
|
||||
4. **Guardar y tomar foto** captura GPS y abre la cámara automáticamente.
|
||||
La 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.
|
||||
|
||||
Se conserva la regla de que un elemento nacido en campo no puede recibir Hallazgos hasta contar con GPS y fotografía.
|
||||
También se incorporó la marca oficial de Mendoza provista para esta release en las pantallas de acceso y desbloqueo biométrico.
|
||||
|
||||
## Actas en Android
|
||||
## Integridad del alta de Inventario
|
||||
|
||||
El espacio de Actas fue rediseñado para mostrar con claridad:
|
||||
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 de la Inspección y Yacimiento;
|
||||
- estado de cada Acta y cantidad de Hallazgos;
|
||||
- urgencia mediante selección explícita;
|
||||
- responsable de empresa, bloqueo, firmas, manifestación y sellado;
|
||||
- condición necesaria para cerrar la Inspección.
|
||||
El servidor continúa siendo la autoridad para:
|
||||
|
||||
El listado que consume Android usa un read-model móvil liviano (`/inspection-visits/:visitId/acts/mobile`). Esta lectura no depende del módulo documental de oficina ni de `inspection_reports`, de modo que entrar a una Inspección en campo no queda acoplado a la proyección de Informes/GEDO.
|
||||
- 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
|
||||
|
||||
@@ -42,12 +60,12 @@ Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar `And
|
||||
|
||||
1. validación de identidad, HTTPS y políticas básicas del manifest;
|
||||
2. Android lint;
|
||||
3. unit tests Android reales, con verificación de que exista al menos una prueba ejecutada;
|
||||
3. 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 el cambio.
|
||||
Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de incorporar la release a `main`.
|
||||
|
||||
## Firma release
|
||||
|
||||
@@ -62,5 +80,5 @@ Antes de distribuir una APK productiva:
|
||||
- todas las barreras de CI del SHA exacto deben estar verdes;
|
||||
- comprobar certificado/huella de firma contra la versión histórica;
|
||||
- realizar actualización sobre al menos una tablet con la versión productiva anterior;
|
||||
- ejecutar smoke funcional contra el entorno objetivo: login, abrir Inspección, abrir Actas sin error, crear Acta, búsqueda dinámica de Instalación, selección de padre, alta de Subinstalación, GPS/foto, Hallazgo y cierre;
|
||||
- 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.
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "com.korexlabs.dhinspeccion"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 27
|
||||
versionName = "0.18.0"
|
||||
versionCode = 28
|
||||
versionName = "0.19.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
@@ -161,7 +161,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val currentVisit = visit ?: return
|
||||
val asset = selectedFieldAsset?.asset
|
||||
if (currentVisit.status != "IN_PROGRESS") {
|
||||
error = "La Inspección debe estar en curso para crear un Acta."
|
||||
error = "La inspección debe estar en curso para crear un Acta."
|
||||
return
|
||||
}
|
||||
if (urgency !in setOf("URGENT", "NON_URGENT")) {
|
||||
@@ -169,7 +169,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return
|
||||
}
|
||||
if (acts.any { it.status == "DRAFT" }) {
|
||||
error = "Ya existe un Acta en borrador. Bloqueala 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,6 +237,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
familyId: String?,
|
||||
name: String,
|
||||
commonName: String?,
|
||||
description: String?,
|
||||
discoveryNotes: String?,
|
||||
attributes: Map<String, Any?>,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
@@ -244,7 +246,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
) {
|
||||
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) {
|
||||
@@ -258,6 +260,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
familyId = familyId,
|
||||
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,
|
||||
@@ -265,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)
|
||||
}
|
||||
}
|
||||
@@ -319,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."
|
||||
}
|
||||
@@ -419,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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,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 sellar el Acta."
|
||||
notice = "Ausencia del responsable registrada. Al cerrar el Acta, dejá asentada la ausencia en el circuito de firma."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,12 +484,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
launchBusy {
|
||||
actClosure = actsRepository.lock(actId)
|
||||
refreshSelectedActInternal(actId)
|
||||
notice = "Acta bloqueada. Su contenido quedó inmutable; ahora deben resolverse las firmas y manifestaciones."
|
||||
notice = "Acta cerrada y pendiente de firma. El contenido y los Hallazgos quedaron inmutables."
|
||||
}
|
||||
}
|
||||
|
||||
fun reopenSelectedAct() {
|
||||
error = "Un Acta bloqueada es inmutable y no puede volver a borrador."
|
||||
error = "Un Acta cerrada y pendiente de firma es inmutable y no puede volver a borrador."
|
||||
}
|
||||
|
||||
fun signSelectedActAsInspector(
|
||||
@@ -524,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) {
|
||||
@@ -534,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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,7 +554,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
refreshSelectedActInternal(actId)
|
||||
loadActsInternal(currentVisit.id, selectDraft = false)
|
||||
clearFindingState()
|
||||
notice = "${selectedAct?.code ?: "Acta"} SELLADA e inmutable. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
|
||||
notice = "${selectedAct?.code ?: "Acta"} firmada, finalizada e inmutable. Podés crear otra Acta o continuar hacia el cierre de la inspección."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,7 +563,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
launchBusy {
|
||||
visit = actsRepository.closeVisit(visitId)
|
||||
loadVisitsInternal()
|
||||
notice = "Inspección cerrada. Todas sus Actas quedaron SELLADAS y disponibles para el circuito de oficina."
|
||||
notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ fun FieldFindingScreen(model: MainViewModel) {
|
||||
Text(asset.name, fontWeight = FontWeight.Bold)
|
||||
Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "OK" else "pendiente"}",
|
||||
"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"} · ${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) }
|
||||
|
||||
+32
@@ -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,
|
||||
)
|
||||
@@ -3,12 +3,14 @@ package com.korexlabs.dhinspeccion.ui
|
||||
import android.content.Context
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.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
|
||||
@@ -30,6 +32,7 @@ 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.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
@@ -37,6 +40,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.korexlabs.dhinspeccion.MainViewModel
|
||||
import com.korexlabs.dhinspeccion.R
|
||||
|
||||
private const val BIOMETRIC_PREFS = "dh_v2_biometric"
|
||||
private const val BIOMETRIC_ENABLED = "enabled"
|
||||
@@ -98,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("") }
|
||||
@@ -106,14 +127,13 @@ private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Uni
|
||||
|
||||
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Aplicación de campo · Dirección de Hidrocarburos")
|
||||
MendozaBrand()
|
||||
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
|
||||
OutlinedTextField(
|
||||
value = identifier,
|
||||
onValueChange = { identifier = it },
|
||||
label = { Text("Usuario o email") },
|
||||
label = { Text("Usuario o correo electrónico") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
@@ -198,6 +218,11 @@ private fun BiometricUnlockScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.logo_mendoza_oficial),
|
||||
contentDescription = "Gobierno de Mendoza",
|
||||
modifier = Modifier.size(120.dp),
|
||||
)
|
||||
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Ingresá con tu huella")
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
|
||||
@@ -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(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"
|
||||
}
|
||||
|
||||
+56
-31
@@ -245,7 +245,7 @@ fun ModernMobileActsScreen(
|
||||
) {
|
||||
Text(
|
||||
if (newActUrgency == "URGENT") {
|
||||
"El plazo urgente se computará desde el Acta conforme a la política institucional vigente al bloquearla."
|
||||
"El plazo urgente se computará 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,12 +319,16 @@ fun ModernMobileActsScreen(
|
||||
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->
|
||||
FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(kind) })
|
||||
FilterChip(
|
||||
selected = documentType == kind,
|
||||
onClick = { documentType = kind },
|
||||
label = { Text(documentTypeLabel(kind)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedTextField(email, { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedTextField(email, { email = it }, label = { Text("Correo electrónico") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
Button(
|
||||
onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) },
|
||||
@@ -353,17 +357,24 @@ fun ModernMobileActsScreen(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.error)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text(
|
||||
"Al bloquear el Acta, su contenido y sus Hallazgos quedan inmutables.",
|
||||
"Al cerrar el Acta, su contenido y sus Hallazgos quedan inmutables. El Acta quedará pendiente de firma.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(
|
||||
onClick = { model.prepareSelectedAct() },
|
||||
enabled = !model.busy && closure?.responsible != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Finalizar y BLOQUEAR Acta") }
|
||||
) { Text("Cerrar Acta y dejar pendiente de firma") }
|
||||
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,17 +383,17 @@ 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)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.secondary)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Acta pendiente de firma", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text("El contenido ya es inmutable. Restan firmas y manifestaciones para poder sellarla.")
|
||||
closure?.closure?.preparedSha256?.let { Text("Hash · $it", style = MaterialTheme.typography.bodySmall) }
|
||||
Text("El contenido ya 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 -> 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 sellar.",
|
||||
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("SELLAR Acta definitivamente")
|
||||
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 SELLADA e inmutable", fontWeight = FontWeight.Bold)
|
||||
Text("Desde este sellado se genera el PDF del Acta y el INF Word editable 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 SELLADAS")
|
||||
SuccessLine("Todas las Actas están finalizadas")
|
||||
} else {
|
||||
Text(
|
||||
"Falta SELLAR: ${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,12 +548,20 @@ private fun SuccessLine(text: String) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun documentTypeLabel(kind: String): String = when (kind) {
|
||||
"DNI" -> "DNI"
|
||||
"CUIL" -> "CUIL"
|
||||
"PASSPORT" -> "Pasaporte"
|
||||
"OTHER" -> "Otro"
|
||||
else -> "Otro"
|
||||
}
|
||||
|
||||
private fun modernActStatusLabel(status: String): String = when (status) {
|
||||
"DRAFT" -> "Borrador"
|
||||
"LOCKED" -> "Bloqueada"
|
||||
"SEALED" -> "Sellada"
|
||||
"LOCKED" -> "Pendiente de firma"
|
||||
"SEALED" -> "Finalizada"
|
||||
"CANCELLED" -> "Cancelada"
|
||||
else -> status
|
||||
else -> "Estado no reconocido"
|
||||
}
|
||||
|
||||
private fun hasModernActLocation(context: Context): Boolean =
|
||||
|
||||
@@ -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,
|
||||
@@ -274,7 +274,7 @@ private fun ModernChecklistCard(visit: VisitDetail) {
|
||||
)
|
||||
}
|
||||
if (checklist.stale) {
|
||||
Text("El checklist requiere actualización.", color = MaterialTheme.colorScheme.error)
|
||||
Text("La lista de control requiere actualización.", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,6 +296,8 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
||||
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var name by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var description by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var 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("") }
|
||||
@@ -311,6 +313,8 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
||||
fun resetForm() {
|
||||
name = ""
|
||||
commonName = ""
|
||||
description = ""
|
||||
discoveryNotes = ""
|
||||
selectedTypeId = null
|
||||
selectedFamilyId = null
|
||||
familySearch = ""
|
||||
@@ -427,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." }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,13 +448,15 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
||||
familyId = selectedFamilyId,
|
||||
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." }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -800,8 +806,8 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Nombre o código visible *") },
|
||||
supportingText = { Text("Usá lo que figura en placa o identifica el elemento en campo.") },
|
||||
label = { Text("Nombre técnico / 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,
|
||||
)
|
||||
@@ -812,6 +818,21 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Descripción (opcional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = discoveryNotes,
|
||||
onValueChange = { discoveryNotes = it },
|
||||
label = { Text("Observaciones del alta (opcional)") },
|
||||
supportingText = { Text("Anotá datos útiles para la revisión posterior en oficina.") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
)
|
||||
|
||||
selectedType?.attributes?.forEach { definition ->
|
||||
OutlinedTextField(
|
||||
@@ -830,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),
|
||||
)
|
||||
@@ -927,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,
|
||||
@@ -966,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) {
|
||||
@@ -1078,7 +1099,7 @@ private fun modernStatusLabel(status: String): String = when (status) {
|
||||
"IN_PROGRESS" -> "En curso"
|
||||
"CLOSED" -> "Cerrada"
|
||||
"CANCELLED" -> "Cancelada"
|
||||
else -> status
|
||||
else -> "Estado no reconocido"
|
||||
}
|
||||
|
||||
private fun buildModernAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
|
||||
@@ -1112,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) }
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
+68
@@ -0,0 +1,68 @@
|
||||
package com.korexlabs.dhinspeccion
|
||||
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FinalFieldFlowContractTest {
|
||||
private val home = File("src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt").readText()
|
||||
private val visit = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt").readText()
|
||||
private val acts = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt").readText()
|
||||
private val findings = File("src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt").readText()
|
||||
private val viewModel = File("src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt").readText()
|
||||
|
||||
@Test
|
||||
fun inspectionIsPlannedInOfficeAndOnlyStartedOnTablet() {
|
||||
assertTrue(home.contains("Trabajo planificado desde oficina"))
|
||||
assertFalse(home.contains("MobileInspectionOpenRepository"))
|
||||
assertFalse(home.contains("Abrir inspección ahora"))
|
||||
assertTrue(home.contains("fieldVisits = model.visits.filter { it.status != \"DRAFT\" }"))
|
||||
assertTrue(home.contains("\"DRAFT\" -> \"Borrador\""))
|
||||
assertTrue(visit.contains("Iniciar inspección"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun findingsAreScopedToInstallationsAndSubinstallations() {
|
||||
assertTrue(visit.contains("setOf(\"instalacion\", \"subinstalacion\")"))
|
||||
assertTrue(visit.contains("Buscar existente o cargar Instalación / Subinstalación"))
|
||||
assertTrue(visit.contains("Guardar y tomar foto"))
|
||||
assertTrue(visit.contains("La foto es obligatoria antes de registrar Hallazgos"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fieldCreationKeepsDashboardIdentityAndTechnicalRequirements() {
|
||||
assertTrue(visit.contains("Nombre técnico / identificación *"))
|
||||
assertTrue(visit.contains("El Código DH de campo se genera automáticamente"))
|
||||
assertTrue(visit.contains("Clasificación técnica"))
|
||||
assertTrue(visit.contains("description = description"))
|
||||
assertTrue(visit.contains("discoveryNotes = discoveryNotes"))
|
||||
assertTrue(viewModel.contains("description = description?.trim()"))
|
||||
assertTrue(viewModel.contains("discoveryNotes = discoveryNotes?.trim()"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closingActLeavesItPendingForSignatureAndResolvesAbsenceCorrectly() {
|
||||
assertTrue(acts.contains("Cerrar Acta y dejar pendiente de firma"))
|
||||
assertTrue(acts.contains("Acta pendiente de firma"))
|
||||
assertTrue(viewModel.contains("Acta cerrada y pendiente de firma"))
|
||||
assertTrue(acts.contains("Registrar ausencia para la firma"))
|
||||
assertTrue(acts.contains("recordCompanyOutcome(\"ABSENT\""))
|
||||
assertTrue(viewModel.contains("status !in setOf(\"REFUSED\", \"ABSENT\")"))
|
||||
assertTrue(acts.contains("Finalizar Acta"))
|
||||
assertTrue(acts.contains("Acta finalizada e inmutable"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activeFieldScreensDoNotExposeKnownEnglishLabels() {
|
||||
val activeUi = home + visit + acts + findings
|
||||
assertFalse(activeUi.contains("Text(\"Email\")"))
|
||||
assertFalse(activeUi.contains("El checklist requiere actualización"))
|
||||
assertFalse(activeUi.contains("Acta BLOQUEADA"))
|
||||
assertFalse(activeUi.contains("SELLAR Acta"))
|
||||
assertFalse(activeUi.contains("Acta SELLADA"))
|
||||
assertTrue(acts.contains("\"PASSPORT\" -> \"Pasaporte\""))
|
||||
assertTrue(acts.contains("\"OTHER\" -> \"Otro\""))
|
||||
assertTrue(findings.contains("findingStatusLabel(finding.status)"))
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
|
||||
@Test
|
||||
fun debugBuildKeepsSeparateApplicationIdentity() {
|
||||
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
|
||||
assertEquals(27, BuildConfig.VERSION_CODE)
|
||||
assertEquals("0.18.0-debug", BuildConfig.VERSION_NAME)
|
||||
assertEquals(28, BuildConfig.VERSION_CODE)
|
||||
assertEquals("0.19.0-debug", BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -7,11 +7,11 @@ 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 = 27/);
|
||||
assert.match(gradle, /versionName = "0\.18\.0"/);
|
||||
assert.match(gradle, /versionCode = 28/);
|
||||
assert.match(gradle, /versionName = "0\.19\.0"/);
|
||||
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
||||
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
|
||||
});
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import { AccessDeniedPage, NotFoundPage } from '../pages/SystemPages';
|
||||
import { UserDetailPage } from '../pages/UserDetailPage';
|
||||
import { UsersPage } from '../pages/UsersPage';
|
||||
import { AssetsPage } from '../pages/AssetsPage';
|
||||
import { AssetEditorPage } from '../pages/AssetEditorPage';
|
||||
import { InventoryDetailPage } from '../pages/InventoryDetailPage';
|
||||
import { InventoryCreatePage } from '../pages/InventoryCreatePage';
|
||||
import { FieldDiscoveriesPage } from '../pages/FieldDiscoveriesPage';
|
||||
import { AuthoritativeInventoryConfigPage } from '../pages/AuthoritativeInventoryConfigPage';
|
||||
@@ -49,9 +49,9 @@ export function App() {
|
||||
<Route element={<PermissionRoute permission="assets.read" />}><Route path="/mapa" element={<MapPage />} /></Route>
|
||||
<Route element={<PermissionRoute permission="assets.read" />}>
|
||||
<Route path="/inventarios" element={<AssetsPage />} />
|
||||
<Route path="/inventarios/:id" element={<AssetEditorPage />} />
|
||||
<Route path="/inventarios/:id" element={<InventoryDetailPage />} />
|
||||
<Route path="/activos" element={<Navigate to="/inventarios" replace />} />
|
||||
<Route path="/activos/:id" element={<AssetEditorPage />} />
|
||||
<Route path="/activos/:id" element={<InventoryDetailPage />} />
|
||||
</Route>
|
||||
<Route element={<PermissionRoute permission="assets.change_status" />}><Route path="/inventarios/revision-campo" element={<FieldDiscoveriesPage />} /></Route>
|
||||
<Route element={<PermissionRoute permission="assets.create" />}><Route path="/inventarios/nuevo" element={<InventoryCreatePage />} /><Route path="/activos/nuevo" element={<Navigate to="/inventarios/nuevo" replace />} /></Route>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { AuthUser } from '../lib/api';
|
||||
|
||||
function normalizeRole(value: string) {
|
||||
return value
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const ADMIN_ROLES = new Set([
|
||||
'admin',
|
||||
'administrator',
|
||||
'administrador',
|
||||
'superadmin',
|
||||
'super admin',
|
||||
'superadministrator',
|
||||
'super administrator',
|
||||
'superadministrador',
|
||||
'super administrador',
|
||||
]);
|
||||
|
||||
export function hasAdministratorRole(user: AuthUser | null) {
|
||||
return Boolean(user?.roles.some((role) => ADMIN_ROLES.has(normalizeRole(role))));
|
||||
}
|
||||
@@ -34,10 +34,10 @@ function supportsFunctionChange(code: string, name: string): boolean {
|
||||
|
||||
export function AssetHistoryPanel({
|
||||
assetId,
|
||||
refreshKey,
|
||||
refreshKey = 0,
|
||||
}: {
|
||||
assetId: string;
|
||||
refreshKey: number;
|
||||
refreshKey?: number;
|
||||
}) {
|
||||
const [versions, setVersions] = useState<AssetVersionSummary[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
@@ -52,12 +52,12 @@ export function AssetMediaPanel({
|
||||
assetId,
|
||||
assetName,
|
||||
canManage,
|
||||
onChanged,
|
||||
onChanged = () => undefined,
|
||||
}: {
|
||||
assetId: string;
|
||||
assetName: string;
|
||||
canManage: boolean;
|
||||
onChanged: () => void;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
const [items, setItems] = useState<AssetMedia[]>([]);
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel';
|
||||
import { AssetMediaPanel } from '../features/assets/AssetMediaPanel';
|
||||
import { assetStatusClass, assetStatusLabel } from '../features/assets/assetPresentation';
|
||||
import {
|
||||
createAreaCompanyRelation,
|
||||
endAreaCompanyRelation,
|
||||
getAssetRegistry,
|
||||
listAreaCompanyRelations,
|
||||
listInspectionVisits,
|
||||
listOperationalAreas,
|
||||
updateAsset,
|
||||
upsertOrganizationProfile,
|
||||
} from '../lib/api';
|
||||
import type {
|
||||
AreaCompanyRelation,
|
||||
AssetDetail,
|
||||
AssetRegistry,
|
||||
OperationalAssetSummary,
|
||||
OrganizationKind,
|
||||
} from '../lib/api';
|
||||
import {
|
||||
listInspectionActsGlobalF4,
|
||||
type InspectionActListItemF4,
|
||||
type InspectionActStatusF4,
|
||||
} from '../lib/inspectionActF4Api';
|
||||
import {
|
||||
listInspectionReportsF4,
|
||||
type InspectionReportDetailF4,
|
||||
} from '../lib/reportWorkflowApi';
|
||||
import { formatDate } from '../lib/format';
|
||||
import './companyInventory.css';
|
||||
|
||||
type CompanyTab = 'summary' | 'issued' | 'documents' | 'history';
|
||||
|
||||
const organizationLabels: Record<OrganizationKind, string> = {
|
||||
COMPANY: 'Empresa',
|
||||
UTE: 'UTE',
|
||||
PUBLIC_ENTITY: 'Entidad pública',
|
||||
OTHER: 'Otra organización',
|
||||
};
|
||||
|
||||
function relationRoleLabel(role: AreaCompanyRelation['relationRole']) {
|
||||
if (role === 'OPERATOR') return 'Operadora';
|
||||
if (role === 'TECHNICAL_OPERATOR') return 'Operadora técnica';
|
||||
if (role === 'CONCESSIONAIRE') return 'Concesionaria / titular';
|
||||
if (role === 'PERMIT_HOLDER') return 'Permisionaria';
|
||||
if (role === 'PARTICIPANT') return 'Participante';
|
||||
return 'Otro vínculo';
|
||||
}
|
||||
|
||||
function reportStatusLabel(report: InspectionReportDetailF4) {
|
||||
if (report.status === 'OFFICIALIZED') return report.gedoIfIdentifier ? `GEDO · ${report.gedoIfIdentifier}` : 'Oficializado en GEDO';
|
||||
if (report.status === 'WORKING') return 'INF en preparación';
|
||||
if (report.status === 'CANCELLED') return 'Cancelado';
|
||||
return 'Informe histórico';
|
||||
}
|
||||
|
||||
function reportStatusClass(report: InspectionReportDetailF4) {
|
||||
if (report.status === 'OFFICIALIZED') return 'active';
|
||||
if (report.status === 'CANCELLED') return 'danger';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function areaLabel(items: Array<{ name: string }>) {
|
||||
if (!items.length) return 'Área sin informar';
|
||||
const first = items[0];
|
||||
if (!first) return 'Área sin informar';
|
||||
return items.length === 1 ? first.name : `${first.name} +${items.length - 1}`;
|
||||
}
|
||||
|
||||
async function loadAllActs(companyId: string): Promise<InspectionActListItemF4[]> {
|
||||
const statuses: InspectionActStatusF4[] = ['SEALED', 'CLOSED', 'RECTIFIED'];
|
||||
const groups = await Promise.all(statuses.map(async (status) => {
|
||||
const collected: InspectionActListItemF4[] = [];
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
do {
|
||||
const response = await listInspectionActsGlobalF4({ companyId, status, page, pageSize: 100 });
|
||||
collected.push(...response.data);
|
||||
totalPages = response.meta.totalPages;
|
||||
page += 1;
|
||||
} while (page <= totalPages);
|
||||
return collected;
|
||||
}));
|
||||
return groups.flat().sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime());
|
||||
}
|
||||
|
||||
async function loadAllReports(companyId: string): Promise<InspectionReportDetailF4[]> {
|
||||
const collected: InspectionReportDetailF4[] = [];
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
do {
|
||||
const response = await listInspectionReportsF4({ companyId, page, pageSize: 100 });
|
||||
collected.push(...response.data);
|
||||
totalPages = response.meta.totalPages;
|
||||
page += 1;
|
||||
} while (page <= totalPages);
|
||||
return collected
|
||||
.filter((item) => item.status !== 'CANCELLED')
|
||||
.sort((a, b) => new Date(b.generatedAt).getTime() - new Date(a.generatedAt).getTime());
|
||||
}
|
||||
|
||||
export function CompanyInventoryPage({ initialAsset }: { initialAsset: AssetDetail }) {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const requestedTab = params.get('tab') as CompanyTab | null;
|
||||
const tab: CompanyTab = requestedTab && ['summary', 'issued', 'documents', 'history'].includes(requestedTab)
|
||||
? requestedTab
|
||||
: 'summary';
|
||||
const { hasPermission } = useAuth();
|
||||
const canEditAsset = hasPermission('assets.update');
|
||||
const canReadRegistry = hasPermission('asset_registry.read');
|
||||
const canManageRegistry = hasPermission('asset_registry.manage');
|
||||
const canReadRelations = hasPermission('asset_relations.read');
|
||||
const canManageRelations = hasPermission('asset_relations.manage');
|
||||
const canReadActs = hasPermission('inspection_acts.read');
|
||||
const canReadReports = hasPermission('inspection_reports.read');
|
||||
const canReadInspections = hasPermission('inspections.read');
|
||||
const canReadMedia = hasPermission('assets.read_media');
|
||||
const canManageMedia = hasPermission('assets.manage_media');
|
||||
const canReadHistory = hasPermission('assets.read_history');
|
||||
|
||||
const [asset, setAsset] = useState(initialAsset);
|
||||
const [registry, setRegistry] = useState<AssetRegistry | null>(null);
|
||||
const [relations, setRelations] = useState<AreaCompanyRelation[]>([]);
|
||||
const [areas, setAreas] = useState<OperationalAssetSummary[]>([]);
|
||||
const [loadingContext, setLoadingContext] = useState(true);
|
||||
const [contextError, setContextError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [companyForm, setCompanyForm] = useState({
|
||||
name: initialAsset.name,
|
||||
commonName: initialAsset.commonName ?? '',
|
||||
organizationKind: 'COMPANY' as OrganizationKind,
|
||||
legalName: initialAsset.name,
|
||||
taxId: '',
|
||||
notificationEmail: '',
|
||||
notes: '',
|
||||
});
|
||||
const [relationForm, setRelationForm] = useState({ areaId: '', reason: '' });
|
||||
const [metrics, setMetrics] = useState({ acts: 0, reports: 0, activeInspections: 0 });
|
||||
const [acts, setActs] = useState<InspectionActListItemF4[]>([]);
|
||||
const [reports, setReports] = useState<InspectionReportDetailF4[]>([]);
|
||||
const [documentsLoading, setDocumentsLoading] = useState(false);
|
||||
const [documentsError, setDocumentsError] = useState('');
|
||||
const [documentSearch, setDocumentSearch] = useState('');
|
||||
|
||||
const activeRelations = useMemo(() => relations.filter((item) => item.active), [relations]);
|
||||
|
||||
const loadContext = async () => {
|
||||
setLoadingContext(true);
|
||||
setContextError('');
|
||||
try {
|
||||
const [loadedRegistry, loadedRelations, loadedAreas] = await Promise.all([
|
||||
canReadRegistry ? getAssetRegistry(asset.id) : Promise.resolve(null),
|
||||
canReadRelations ? listAreaCompanyRelations({ companyId: asset.id, includeHistory: true }) : Promise.resolve([]),
|
||||
canManageRelations ? listOperationalAreas() : Promise.resolve([]),
|
||||
]);
|
||||
setRegistry(loadedRegistry);
|
||||
setRelations(loadedRelations);
|
||||
setAreas(loadedAreas);
|
||||
if (loadedRegistry?.organizationProfile) {
|
||||
const profile = loadedRegistry.organizationProfile;
|
||||
setCompanyForm({
|
||||
name: asset.name,
|
||||
commonName: asset.commonName ?? '',
|
||||
organizationKind: profile.organizationKind,
|
||||
legalName: profile.legalName ?? asset.name,
|
||||
taxId: profile.taxId ?? '',
|
||||
notificationEmail: profile.notificationEmail ?? '',
|
||||
notes: profile.notes ?? '',
|
||||
});
|
||||
}
|
||||
} catch (requestError) {
|
||||
setContextError(errorMessage(requestError));
|
||||
} finally {
|
||||
setLoadingContext(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { void loadContext(); }, [asset.id]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadMetrics = async () => {
|
||||
try {
|
||||
const [sealed, closed, rectified, reportPage, planned, inProgress] = await Promise.all([
|
||||
canReadActs ? listInspectionActsGlobalF4({ companyId: asset.id, status: 'SEALED', pageSize: 1 }) : Promise.resolve(null),
|
||||
canReadActs ? listInspectionActsGlobalF4({ companyId: asset.id, status: 'CLOSED', pageSize: 1 }) : Promise.resolve(null),
|
||||
canReadActs ? listInspectionActsGlobalF4({ companyId: asset.id, status: 'RECTIFIED', pageSize: 1 }) : Promise.resolve(null),
|
||||
canReadReports ? listInspectionReportsF4({ companyId: asset.id, pageSize: 1 }) : Promise.resolve(null),
|
||||
canReadInspections ? listInspectionVisits({ companyId: asset.id, status: 'PLANNED', pageSize: 1 }) : Promise.resolve(null),
|
||||
canReadInspections ? listInspectionVisits({ companyId: asset.id, status: 'IN_PROGRESS', pageSize: 1 }) : Promise.resolve(null),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setMetrics({
|
||||
acts: (sealed?.meta.total ?? 0) + (closed?.meta.total ?? 0) + (rectified?.meta.total ?? 0),
|
||||
reports: reportPage?.meta.total ?? 0,
|
||||
activeInspections: (planned?.meta.total ?? 0) + (inProgress?.meta.total ?? 0),
|
||||
});
|
||||
} catch {
|
||||
if (!cancelled) setMetrics({ acts: 0, reports: 0, activeInspections: 0 });
|
||||
}
|
||||
};
|
||||
void loadMetrics();
|
||||
return () => { cancelled = true; };
|
||||
}, [asset.id, canReadActs, canReadReports, canReadInspections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'issued' || (!canReadActs && !canReadReports)) return;
|
||||
let cancelled = false;
|
||||
setDocumentsLoading(true);
|
||||
setDocumentsError('');
|
||||
Promise.all([
|
||||
canReadActs ? loadAllActs(asset.id) : Promise.resolve([]),
|
||||
canReadReports ? loadAllReports(asset.id) : Promise.resolve([]),
|
||||
])
|
||||
.then(([loadedActs, loadedReports]) => {
|
||||
if (cancelled) return;
|
||||
setActs(loadedActs);
|
||||
setReports(loadedReports);
|
||||
})
|
||||
.catch((requestError) => { if (!cancelled) setDocumentsError(errorMessage(requestError)); })
|
||||
.finally(() => { if (!cancelled) setDocumentsLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [tab, asset.id, canReadActs, canReadReports]);
|
||||
|
||||
const saveCompany = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setSaving(true); setContextError(''); setSuccess('');
|
||||
try {
|
||||
let savedAsset = asset;
|
||||
if (canEditAsset) {
|
||||
savedAsset = await updateAsset(asset.id, {
|
||||
name: companyForm.name.trim(),
|
||||
commonName: companyForm.commonName.trim() || null,
|
||||
});
|
||||
setAsset(savedAsset);
|
||||
}
|
||||
if (canManageRegistry) {
|
||||
await upsertOrganizationProfile(asset.id, {
|
||||
organizationKind: companyForm.organizationKind,
|
||||
legalName: companyForm.legalName.trim() || null,
|
||||
taxId: companyForm.taxId.trim() || null,
|
||||
notificationEmail: companyForm.notificationEmail.trim() || null,
|
||||
notes: companyForm.notes.trim() || null,
|
||||
});
|
||||
}
|
||||
setEditing(false);
|
||||
setSuccess('Datos de la empresa actualizados.');
|
||||
await loadContext();
|
||||
} catch (requestError) {
|
||||
setContextError(errorMessage(requestError));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addRelation = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!relationForm.areaId || relationForm.reason.trim().length < 3) return;
|
||||
setSaving(true); setContextError(''); setSuccess('');
|
||||
try {
|
||||
await createAreaCompanyRelation({
|
||||
areaId: relationForm.areaId,
|
||||
companyId: asset.id,
|
||||
reason: relationForm.reason.trim(),
|
||||
});
|
||||
setRelationForm({ areaId: '', reason: '' });
|
||||
setSuccess('Área vinculada a la empresa.');
|
||||
await loadContext();
|
||||
} catch (requestError) {
|
||||
setContextError(errorMessage(requestError));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const endRelation = async (relation: AreaCompanyRelation) => {
|
||||
const reason = window.prompt(`Motivo para finalizar el vínculo con ${relation.area.name}`);
|
||||
if (!reason?.trim() || reason.trim().length < 3) return;
|
||||
setSaving(true); setContextError(''); setSuccess('');
|
||||
try {
|
||||
await endAreaCompanyRelation(relation.id, reason.trim());
|
||||
setSuccess('Vínculo finalizado. El historial quedó conservado.');
|
||||
await loadContext();
|
||||
} catch (requestError) {
|
||||
setContextError(errorMessage(requestError));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setTab = (next: CompanyTab) => {
|
||||
const nextParams = new URLSearchParams(params);
|
||||
next === 'summary' ? nextParams.delete('tab') : nextParams.set('tab', next);
|
||||
nextParams.delete('advanced');
|
||||
setParams(nextParams);
|
||||
};
|
||||
|
||||
const reportByAct = useMemo(() => new Map(reports.map((report) => [report.actId, report])), [reports]);
|
||||
const filteredActs = useMemo(() => {
|
||||
const needle = documentSearch.trim().toLocaleLowerCase('es');
|
||||
if (!needle) return acts;
|
||||
return acts.filter((act) => [
|
||||
act.code,
|
||||
act.title,
|
||||
act.visit.code,
|
||||
...act.areas.map((item) => item.name),
|
||||
reportByAct.get(act.id)?.code ?? '',
|
||||
reportByAct.get(act.id)?.gedoIfIdentifier ?? '',
|
||||
].join(' ').toLocaleLowerCase('es').includes(needle));
|
||||
}, [acts, documentSearch, reportByAct]);
|
||||
|
||||
const usedAreaIds = new Set(activeRelations.map((relation) => relation.area.id));
|
||||
const availableAreas = areas.filter((item) => !usedAreaIds.has(item.id));
|
||||
const profile = registry?.organizationProfile ?? null;
|
||||
|
||||
return <section className="narrow-section company-page">
|
||||
<nav className="breadcrumb company-breadcrumb" aria-label="Ruta de empresa">
|
||||
<Link to="/inventarios">Inventarios</Link><span>›</span>
|
||||
<Link to="/inventarios?section=companies">Empresas</Link><span>›</span><strong>{asset.name}</strong>
|
||||
</nav>
|
||||
|
||||
<header className="company-hero">
|
||||
<div>
|
||||
<span className="eyebrow">EMPRESA</span>
|
||||
<h1>{asset.name}</h1>
|
||||
<p><strong>{asset.code}</strong>{profile?.taxId ? ` · CUIT ${profile.taxId}` : ''}</p>
|
||||
</div>
|
||||
<div className="company-hero-actions">
|
||||
<span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span>
|
||||
{(canEditAsset || canManageRegistry) && tab === 'summary' && <button type="button" className="button primary" onClick={() => setEditing((current) => !current)}><Icon name="edit" />{editing ? 'Cerrar edición' : 'Editar empresa'}</button>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="company-tabs" aria-label="Secciones de la empresa">
|
||||
<button type="button" className={tab === 'summary' ? 'active' : ''} onClick={() => setTab('summary')}>Resumen</button>
|
||||
{(canReadActs || canReadReports) && <button type="button" className={tab === 'issued' ? 'active' : ''} onClick={() => setTab('issued')}>Actas e informes</button>}
|
||||
{(canReadRegistry || canReadMedia) && <button type="button" className={tab === 'documents' ? 'active' : ''} onClick={() => setTab('documents')}>Documentación</button>}
|
||||
{canReadHistory && <button type="button" className={tab === 'history' ? 'active' : ''} onClick={() => setTab('history')}>Historial</button>}
|
||||
</nav>
|
||||
|
||||
{contextError && <Alert>{contextError}</Alert>}
|
||||
{success && <Alert type="success">{success}</Alert>}
|
||||
|
||||
{tab === 'summary' && <div className="company-stack">
|
||||
{loadingContext ? <LoadingBlock label="Cargando empresa…" /> : <>
|
||||
<article className="panel company-card">
|
||||
<div className="company-card-heading">
|
||||
<div><span className="eyebrow">IDENTIDAD</span><h2>Datos de la empresa</h2><p>La información que se usa para identificarla y enviar documentación oficial.</p></div>
|
||||
</div>
|
||||
{editing ? <form className="company-edit-form" onSubmit={saveCompany}>
|
||||
<div className="form-grid">
|
||||
<label className="field"><span>Nombre</span><input value={companyForm.name} disabled={!canEditAsset} onChange={(event) => setCompanyForm({ ...companyForm, name: event.target.value })} required /></label>
|
||||
<label className="field"><span>Nombre corto / sigla <em>opcional</em></span><input value={companyForm.commonName} disabled={!canEditAsset} onChange={(event) => setCompanyForm({ ...companyForm, commonName: event.target.value })} placeholder="Ej.: EMESA" /></label>
|
||||
<label className="field"><span>Tipo</span><SearchableSelect value={companyForm.organizationKind} disabled={!canManageRegistry} onChange={(event) => setCompanyForm({ ...companyForm, organizationKind: event.target.value as OrganizationKind })}>{Object.entries(organizationLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</SearchableSelect></label>
|
||||
<label className="field"><span>Razón social <em>opcional</em></span><input value={companyForm.legalName} disabled={!canManageRegistry} onChange={(event) => setCompanyForm({ ...companyForm, legalName: event.target.value })} /></label>
|
||||
<label className="field"><span>CUIT <em>opcional</em></span><input value={companyForm.taxId} disabled={!canManageRegistry} onChange={(event) => setCompanyForm({ ...companyForm, taxId: event.target.value })} /></label>
|
||||
<label className="field"><span>Email oficial para actas <em>opcional</em></span><input type="email" value={companyForm.notificationEmail} disabled={!canManageRegistry} onChange={(event) => setCompanyForm({ ...companyForm, notificationEmail: event.target.value })} /></label>
|
||||
</div>
|
||||
<label className="field"><span>Notas <em>opcional</em></span><textarea rows={2} value={companyForm.notes} disabled={!canManageRegistry} onChange={(event) => setCompanyForm({ ...companyForm, notes: event.target.value })} /></label>
|
||||
<div className="form-actions"><button type="button" className="button secondary" onClick={() => setEditing(false)}>Cancelar</button><button className="button primary" disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</button></div>
|
||||
</form> : <div className="company-data-grid">
|
||||
<div><small>Razón social</small><strong>{profile?.legalName || asset.name}</strong></div>
|
||||
<div><small>Tipo</small><strong>{organizationLabels[profile?.organizationKind ?? 'COMPANY']}</strong></div>
|
||||
<div><small>CUIT</small><strong>{profile?.taxId || 'Sin informar'}</strong></div>
|
||||
<div><small>Email oficial para actas</small><strong>{profile?.notificationEmail || 'Sin informar'}</strong></div>
|
||||
</div>}
|
||||
</article>
|
||||
|
||||
<article className="panel company-card">
|
||||
<div className="company-card-heading">
|
||||
<div><span className="eyebrow">OPERACIÓN</span><h2>Áreas donde opera</h2><p>Estas relaciones indican dónde participa la empresa. No cambian la jerarquía física del Inventario.</p></div>
|
||||
<span className="count-pill">{activeRelations.length}</span>
|
||||
</div>
|
||||
{activeRelations.length === 0 ? <EmptyState title="Sin áreas vinculadas" text="Todavía no hay áreas operativas asociadas a esta empresa." /> : <div className="company-area-list">{activeRelations.map((relation) => <div className="company-area-row" key={relation.id}>
|
||||
<Link to={`/inventarios/${relation.area.id}`}><strong>{relation.area.name}</strong><small>{relation.area.code} · {relationRoleLabel(relation.relationRole)}</small></Link>
|
||||
<div><span className="status-badge active">Activa</span>{canManageRelations && <button type="button" className="button text compact" disabled={saving} onClick={() => void endRelation(relation)}>Finalizar</button>}</div>
|
||||
</div>)}</div>}
|
||||
{canManageRelations && <details className="company-inline-action"><summary><Icon name="plus" size={15} />Vincular otra área</summary><form onSubmit={addRelation}><div className="form-grid"><label className="field"><span>Área</span><SearchableSelect value={relationForm.areaId} onChange={(event) => setRelationForm({ ...relationForm, areaId: event.target.value })} required><option value="">Seleccionar área…</option>{availableAreas.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.code}</option>)}</SearchableSelect></label><label className="field"><span>Motivo</span><input value={relationForm.reason} onChange={(event) => setRelationForm({ ...relationForm, reason: event.target.value })} minLength={3} required placeholder="Ej.: operación vigente" /></label></div><button className="button primary" disabled={saving || !relationForm.areaId || relationForm.reason.trim().length < 3}>Vincular área</button></form></details>}
|
||||
</article>
|
||||
|
||||
<article className="panel company-card company-activity-card">
|
||||
<div className="company-card-heading"><div><span className="eyebrow">INSPECCIONES</span><h2>Actividad de la empresa</h2><p>Acceso rápido a la documentación que ya fue emitida.</p></div></div>
|
||||
<div className="company-metrics">
|
||||
<div><small>Actas emitidas</small><strong>{metrics.acts}</strong></div>
|
||||
<div><small>Informes</small><strong>{metrics.reports}</strong></div>
|
||||
<div><small>Inspecciones activas</small><strong>{metrics.activeInspections}</strong></div>
|
||||
</div>
|
||||
{(canReadActs || canReadReports) && <div className="company-card-actions"><button type="button" className="button primary" onClick={() => setTab('issued')}>Ver actas e informes <Icon name="chevron" /></button></div>}
|
||||
</article>
|
||||
</>}
|
||||
</div>}
|
||||
|
||||
{tab === 'issued' && <div className="company-stack">
|
||||
<article className="panel company-card">
|
||||
<div className="company-card-heading company-doc-heading"><div><span className="eyebrow">DOCUMENTACIÓN EMITIDA</span><h2>Actas e informes</h2><p>Historial documental de esta empresa. Sólo se muestran Actas ya emitidas y sus Informes asociados.</p></div><div className="company-doc-counts"><span>{acts.length} actas</span><span>{reports.length} informes</span></div></div>
|
||||
<label className="company-document-search"><Icon name="search" /><input value={documentSearch} onChange={(event) => setDocumentSearch(event.target.value)} placeholder="Buscar por acta, informe, inspección, área o GEDO" /></label>
|
||||
{documentsError && <Alert>{documentsError}</Alert>}
|
||||
{documentsLoading ? <LoadingBlock label="Cargando documentación emitida…" /> : filteredActs.length === 0 ? <EmptyState title="Sin documentación emitida" text="Todavía no hay Actas selladas para esta empresa." /> : <div className="table-scroll company-doc-table"><table><thead><tr><th>Fecha</th><th>Área</th><th>Acta</th><th>Inspección</th><th>Informe</th><th>Oficialización</th><th /></tr></thead><tbody>{filteredActs.map((act) => {
|
||||
const report = reportByAct.get(act.id);
|
||||
return <tr key={act.id}>
|
||||
<td>{formatDate(act.occurredAt)}</td>
|
||||
<td><strong className="table-primary">{areaLabel(act.areas)}</strong></td>
|
||||
<td><Link className="text-link" to={`/inspecciones/actas/${act.id}`}>{act.code}</Link></td>
|
||||
<td><Link className="text-link" to={`/inspecciones/${act.visitId}`}>{act.visit.code}</Link></td>
|
||||
<td>{report ? <Link className="text-link" to={`/informes/${report.id}`}>{report.code}</Link> : <span className="status-badge pending">Pendiente de INF</span>}</td>
|
||||
<td>{report ? <span className={`status-badge ${reportStatusClass(report)}`}>{reportStatusLabel(report)}</span> : <span className="muted">—</span>}</td>
|
||||
<td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><Icon name="chevron" /></Link></td>
|
||||
</tr>;
|
||||
})}</tbody></table></div>}
|
||||
</article>
|
||||
</div>}
|
||||
|
||||
{tab === 'documents' && <div className="company-stack">
|
||||
{loadingContext ? <LoadingBlock label="Cargando documentación…" /> : <>
|
||||
{canReadRegistry && <article className="panel company-card">
|
||||
<div className="company-card-heading"><div><span className="eyebrow">RESPALDO</span><h2>Documentos de la empresa</h2><p>Documentos e identificadores que respaldan los datos institucionales.</p></div><Link className="button secondary" to={`/inventarios/${asset.id}?advanced=1&tab=registry`}>Administrar</Link></div>
|
||||
{registry?.sourceDocuments.length ? <div className="company-document-list">{registry.sourceDocuments.map((document) => <div key={document.linkId}><div><strong>{document.title}</strong><small>{document.documentNumber || document.documentType}{document.issuer ? ` · ${document.issuer}` : ''}</small></div><span className="tag">{document.relationType}</span></div>)}</div> : <div className="inline-empty">Todavía no hay documentos respaldatorios vinculados.</div>}
|
||||
{registry?.externalIdentifiers.length ? <details className="company-more"><summary>Ver identificadores externos ({registry.externalIdentifiers.length})</summary><div className="company-identifier-list">{registry.externalIdentifiers.map((item) => <div key={item.id}><strong>{item.namespace}</strong><span>{item.value}</span><small>{item.validUntil ? 'Histórico' : 'Vigente'}</small></div>)}</div></details> : null}
|
||||
</article>}
|
||||
{canReadMedia && <AssetMediaPanel assetId={asset.id} assetName={asset.name} canManage={canManageMedia} />}
|
||||
<div className="company-advanced-link"><span>¿Necesitás procedencia, composición UTE u otros datos técnicos?</span><Link to={`/inventarios/${asset.id}?advanced=1&tab=registry`}>Abrir administración avanzada</Link></div>
|
||||
</>}
|
||||
</div>}
|
||||
|
||||
{tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={asset.id} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { hasAdministratorRole } from '../auth/adminAccess';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { getAsset } from '../lib/api';
|
||||
import type { AssetDetail } from '../lib/api';
|
||||
import { AssetEditorPage } from './AssetEditorPage';
|
||||
import { CompanyInventoryPage } from './CompanyInventoryPage';
|
||||
import { TerritorialInventoryPage, isTerritorialInventoryAsset } from './TerritorialInventoryPage';
|
||||
|
||||
export function InventoryDetailPage() {
|
||||
const { id } = useParams();
|
||||
const [params] = useSearchParams();
|
||||
const { user } = useAuth();
|
||||
const [asset, setAsset] = useState<AssetDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
setError('No se encontró el registro solicitado.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
getAsset(id)
|
||||
.then(setAsset)
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando registro…" />;
|
||||
if (!asset) return <Alert>{error || 'No se pudo cargar el registro.'}</Alert>;
|
||||
|
||||
const advancedRequested = params.get('advanced') === '1';
|
||||
const advancedAllowed = hasAdministratorRole(user);
|
||||
const advanced = advancedRequested && advancedAllowed;
|
||||
const isCompany = asset.type.code.toLowerCase() === 'empresa';
|
||||
|
||||
if (isCompany && !advanced) {
|
||||
return <CompanyInventoryPage initialAsset={asset} />;
|
||||
}
|
||||
|
||||
if (isTerritorialInventoryAsset(asset) && !advanced) {
|
||||
return <TerritorialInventoryPage initialAsset={asset} />;
|
||||
}
|
||||
|
||||
return <AssetEditorPage />;
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { hasAdministratorRole } from '../auth/adminAccess';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel';
|
||||
import { assetStatusClass, assetStatusLabel } from '../features/assets/assetPresentation';
|
||||
import {
|
||||
getAssetLineage,
|
||||
listAreaCompanyRelations,
|
||||
listAssetTreeChildren,
|
||||
listInspectionVisits,
|
||||
updateAsset,
|
||||
} from '../lib/api';
|
||||
import type { AreaCompanyRelation, AssetDetail, AssetLineageItem, AssetListItem } from '../lib/api';
|
||||
import {
|
||||
listInspectionActsGlobalF4,
|
||||
type InspectionActStatusF4,
|
||||
} from '../lib/inspectionActF4Api';
|
||||
import { listInspectionReportsF4 } from '../lib/reportWorkflowApi';
|
||||
import './territorialInventory.css';
|
||||
|
||||
type TerritoryTab = 'summary' | 'history';
|
||||
|
||||
function normalized(value: string) {
|
||||
return value
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function tokensForType(type: { code: string; name: string }) {
|
||||
return new Set(`${normalized(type.code)} ${normalized(type.name)}`.split(' ').filter(Boolean));
|
||||
}
|
||||
|
||||
function typeTokens(asset: AssetDetail) {
|
||||
return tokensForType(asset.type);
|
||||
}
|
||||
|
||||
export function isTerritorialInventoryAsset(asset: AssetDetail) {
|
||||
const tokens = typeTokens(asset);
|
||||
return tokens.has('departamento') || tokens.has('area') || tokens.has('yacimiento');
|
||||
}
|
||||
|
||||
function isOperationalArea(asset: AssetDetail) {
|
||||
return typeTokens(asset).has('area');
|
||||
}
|
||||
|
||||
function isYacimiento(asset: AssetDetail) {
|
||||
return typeTokens(asset).has('yacimiento');
|
||||
}
|
||||
|
||||
function lineageArea(lineage: AssetLineageItem[]) {
|
||||
return [...lineage].reverse().find((item) => tokensForType(item.type).has('area')) ?? null;
|
||||
}
|
||||
|
||||
function relationRoleLabel(role: AreaCompanyRelation['relationRole']) {
|
||||
if (role === 'OPERATOR') return 'Operadora';
|
||||
if (role === 'TECHNICAL_OPERATOR') return 'Operadora técnica';
|
||||
if (role === 'CONCESSIONAIRE') return 'Concesionaria / titular';
|
||||
if (role === 'PERMIT_HOLDER') return 'Permisionaria';
|
||||
if (role === 'PARTICIPANT') return 'Participante';
|
||||
return 'Vinculada';
|
||||
}
|
||||
|
||||
export function TerritorialInventoryPage({ initialAsset }: { initialAsset: AssetDetail }) {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const requestedTab = params.get('tab') as TerritoryTab | null;
|
||||
const tab: TerritoryTab = requestedTab === 'history' ? 'history' : 'summary';
|
||||
const { user, hasPermission } = useAuth();
|
||||
const canEdit = hasPermission('assets.update');
|
||||
const canCreate = hasPermission('assets.create');
|
||||
const canReadHistory = hasPermission('assets.read_history');
|
||||
const canReadRelations = hasPermission('asset_relations.read');
|
||||
const canReadActs = hasPermission('inspection_acts.read');
|
||||
const canReadReports = hasPermission('inspection_reports.read');
|
||||
const canReadInspections = hasPermission('inspections.read');
|
||||
const canAdvanced = hasAdministratorRole(user);
|
||||
|
||||
const [asset, setAsset] = useState(initialAsset);
|
||||
const [children, setChildren] = useState<AssetListItem[]>([]);
|
||||
const [childrenHasMore, setChildrenHasMore] = useState(false);
|
||||
const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
|
||||
const [relations, setRelations] = useState<AreaCompanyRelation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [name, setName] = useState(initialAsset.name);
|
||||
const [description, setDescription] = useState(initialAsset.description ?? '');
|
||||
const [metrics, setMetrics] = useState({ activeInspections: 0, acts: 0, reports: 0 });
|
||||
|
||||
const area = isOperationalArea(asset);
|
||||
const yacimiento = isYacimiento(asset);
|
||||
const activeRelations = useMemo(() => relations.filter((item) => item.active), [relations]);
|
||||
const relationAreaContext = useMemo(() => {
|
||||
if (area) return { id: asset.id, code: asset.code, name: asset.name };
|
||||
if (asset.operationalArea) return asset.operationalArea;
|
||||
const ancestor = lineageArea(lineage);
|
||||
return ancestor ? { id: ancestor.id, code: ancestor.code, name: ancestor.name } : null;
|
||||
}, [area, asset.id, asset.code, asset.name, asset.operationalArea, lineage]);
|
||||
|
||||
const loadSummary = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const loadedLineage = await getAssetLineage(asset.id);
|
||||
const ancestorArea = lineageArea(loadedLineage);
|
||||
const relationAreaId = area
|
||||
? asset.id
|
||||
: asset.operationalArea?.id ?? ancestorArea?.id ?? null;
|
||||
const [childPage, loadedRelations] = await Promise.all([
|
||||
listAssetTreeChildren({ parentId: asset.id, limit: 100 }),
|
||||
relationAreaId && canReadRelations
|
||||
? listAreaCompanyRelations({ areaId: relationAreaId, includeHistory: true })
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
setLineage(loadedLineage);
|
||||
setChildren(childPage.data);
|
||||
setChildrenHasMore(childPage.meta.hasMore);
|
||||
setRelations(loadedRelations);
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { void loadSummary(); }, [asset.id, area, canReadRelations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!area) {
|
||||
setMetrics({ activeInspections: 0, acts: 0, reports: 0 });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const loadMetrics = async () => {
|
||||
try {
|
||||
const actStatuses: InspectionActStatusF4[] = ['SEALED', 'CLOSED', 'RECTIFIED'];
|
||||
const [planned, inProgress, actPages, reportPage] = await Promise.all([
|
||||
canReadInspections ? listInspectionVisits({ areaId: asset.id, status: 'PLANNED', pageSize: 1 }) : Promise.resolve(null),
|
||||
canReadInspections ? listInspectionVisits({ areaId: asset.id, status: 'IN_PROGRESS', pageSize: 1 }) : Promise.resolve(null),
|
||||
canReadActs
|
||||
? Promise.all(actStatuses.map((status) => listInspectionActsGlobalF4({ areaId: asset.id, status, pageSize: 1 })))
|
||||
: Promise.resolve([]),
|
||||
canReadReports ? listInspectionReportsF4({ areaId: asset.id, pageSize: 1 }) : Promise.resolve(null),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setMetrics({
|
||||
activeInspections: (planned?.meta.total ?? 0) + (inProgress?.meta.total ?? 0),
|
||||
acts: actPages.reduce((total, page) => total + page.meta.total, 0),
|
||||
reports: reportPage?.meta.total ?? 0,
|
||||
});
|
||||
} catch {
|
||||
if (!cancelled) setMetrics({ activeInspections: 0, acts: 0, reports: 0 });
|
||||
}
|
||||
};
|
||||
void loadMetrics();
|
||||
return () => { cancelled = true; };
|
||||
}, [asset.id, area, canReadActs, canReadReports, canReadInspections]);
|
||||
|
||||
const save = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!canEdit || !name.trim()) return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const saved = await updateAsset(asset.id, {
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
});
|
||||
setAsset(saved);
|
||||
setName(saved.name);
|
||||
setDescription(saved.description ?? '');
|
||||
setEditing(false);
|
||||
setSuccess('Datos actualizados.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setTab = (next: TerritoryTab) => {
|
||||
const nextParams = new URLSearchParams(params);
|
||||
next === 'summary' ? nextParams.delete('tab') : nextParams.set('tab', next);
|
||||
nextParams.delete('advanced');
|
||||
setParams(nextParams);
|
||||
};
|
||||
|
||||
return <section className="narrow-section territory-page">
|
||||
<nav className="breadcrumb territory-breadcrumb" aria-label="Ruta territorial">
|
||||
<Link to="/inventarios">Inventarios</Link><span>›</span>
|
||||
<Link to="/inventarios?section=territory">Áreas y yacimientos</Link><span>›</span><strong>{asset.name}</strong>
|
||||
</nav>
|
||||
|
||||
<header className="territory-hero">
|
||||
<div>
|
||||
<span className="eyebrow">{asset.type.name.toUpperCase()}</span>
|
||||
<h1>{asset.name}</h1>
|
||||
<p><strong>{asset.code}</strong></p>
|
||||
</div>
|
||||
<div className="territory-hero-actions">
|
||||
<span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span>
|
||||
{canEdit && tab === 'summary' && <button type="button" className="button secondary" onClick={() => setEditing((current) => !current)}><Icon name="edit" />{editing ? 'Cancelar edición' : 'Editar'}</button>}
|
||||
{canCreate && tab === 'summary' && <Link className="button primary" to={`/inventarios/nuevo?parentId=${asset.id}`}><Icon name="plus" />Agregar elemento</Link>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="territory-purpose-note"><Icon name="map" /><span>Este nivel organiza el territorio y la navegación. Los hallazgos se cargan sobre elementos inspeccionables, no sobre {asset.type.name.toLowerCase()}s.</span></div>
|
||||
|
||||
<nav className="territory-tabs" aria-label="Secciones territoriales">
|
||||
<button type="button" className={tab === 'summary' ? 'active' : ''} onClick={() => setTab('summary')}>Resumen</button>
|
||||
{canReadHistory && <button type="button" className={tab === 'history' ? 'active' : ''} onClick={() => setTab('history')}>Historial</button>}
|
||||
</nav>
|
||||
|
||||
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
||||
|
||||
{tab === 'summary' && <div className="territory-stack">
|
||||
<article className="panel territory-card">
|
||||
<div className="territory-card-heading">
|
||||
<div><span className="eyebrow">DATOS BÁSICOS</span><h2>Identificación</h2></div>
|
||||
</div>
|
||||
{editing ? <form className="territory-edit-form" onSubmit={save}>
|
||||
<label className="field"><span>Nombre</span><input value={name} onChange={(event) => setName(event.target.value)} required maxLength={200} /></label>
|
||||
<label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={3} maxLength={4000} /></label>
|
||||
<div className="form-actions"><button type="button" className="button secondary" onClick={() => { setEditing(false); setName(asset.name); setDescription(asset.description ?? ''); }}>Cancelar</button><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar'}</button></div>
|
||||
</form> : <div className="territory-data-grid">
|
||||
<div><small>Tipo</small><strong>{asset.type.name}</strong></div>
|
||||
<div><small>Código DH</small><strong>{asset.code}</strong></div>
|
||||
{asset.description && <div className="wide"><small>Descripción</small><strong>{asset.description}</strong></div>}
|
||||
</div>}
|
||||
</article>
|
||||
|
||||
<article className="panel territory-card">
|
||||
<div className="territory-card-heading">
|
||||
<div><span className="eyebrow">CONTENIDO</span><h2>Registros dentro de {asset.name}</h2><p>Acceso directo a los niveles que dependen de este registro.</p></div>
|
||||
<span className="count-pill">{children.length}{childrenHasMore ? '+' : ''}</span>
|
||||
</div>
|
||||
{loading ? <LoadingBlock label="Cargando contenido…" /> : children.length === 0 ? <div className="inline-empty">Todavía no hay registros dentro de {asset.name}.</div> : <div className="territory-child-list">
|
||||
{children.map((child) => <Link to={`/inventarios/${child.id}`} key={child.id} className="territory-child-row">
|
||||
<span className="asset-symbol"><Icon name="layers" /></span>
|
||||
<span><strong>{child.name}</strong><small>{child.type.name} · {child.code}</small></span>
|
||||
<span className={`status-badge ${assetStatusClass(child.informationStatus)}`}>{assetStatusLabel(child.informationStatus)}</span>
|
||||
<Icon name="chevron" size={16} />
|
||||
</Link>)}
|
||||
</div>}
|
||||
{childrenHasMore && <div className="territory-card-actions"><Link className="button secondary" to={`/inventarios?section=territory&parentId=${asset.id}`}>Ver contenido completo</Link></div>}
|
||||
</article>
|
||||
|
||||
{relationAreaContext && canReadRelations && <article className="panel territory-card">
|
||||
<div className="territory-card-heading"><div><span className="eyebrow">OPERACIÓN</span><h2>Empresas vinculadas</h2><p>{area ? 'Quién opera o participa actualmente en esta área.' : yacimiento ? `Empresas que operan o participan en el Área ${relationAreaContext.name}, contexto operativo de este yacimiento.` : `Empresas vinculadas al Área ${relationAreaContext.name}.`}</p></div><span className="count-pill">{activeRelations.length}</span></div>
|
||||
{loading ? <LoadingBlock label="Cargando empresas…" /> : activeRelations.length === 0 ? <div className="inline-empty">No hay empresas con vínculo vigente en el Área {relationAreaContext.name}.</div> : <div className="territory-company-list">
|
||||
{activeRelations.map((relation) => <Link to={`/inventarios/${relation.company.id}`} key={relation.id} className="territory-company-row"><span><strong>{relation.company.name}</strong><small>{relationRoleLabel(relation.relationRole)}{!area ? ` · ${relation.area.name}` : ''}</small></span><Icon name="chevron" size={16} /></Link>)}
|
||||
</div>}
|
||||
</article>}
|
||||
|
||||
{area && (canReadInspections || canReadActs || canReadReports) && <article className="panel territory-card">
|
||||
<div className="territory-card-heading"><div><span className="eyebrow">ACTIVIDAD</span><h2>Actividad del área</h2><p>Resumen documental y operativo, sin mezclarlo con hallazgos de los elementos inspeccionados.</p></div></div>
|
||||
<div className="territory-metrics">
|
||||
{canReadInspections && <div><small>Inspecciones activas</small><strong>{metrics.activeInspections}</strong></div>}
|
||||
{canReadActs && <div><small>Actas emitidas</small><strong>{metrics.acts}</strong></div>}
|
||||
{canReadReports && <div><small>Informes</small><strong>{metrics.reports}</strong></div>}
|
||||
</div>
|
||||
</article>}
|
||||
|
||||
{canAdvanced && <div className="territory-advanced-link"><span>Los datos estructurales, procedencia, ubicación y opciones técnicas quedan fuera de la vista cotidiana.</span><Link to={`/inventarios/${asset.id}?advanced=1`}>Administración avanzada</Link></div>}
|
||||
</div>}
|
||||
|
||||
{tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={asset.id} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
.company-page { display: grid; gap: 18px; }
|
||||
.company-breadcrumb { margin-bottom: 0; }
|
||||
|
||||
.company-hero {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 4px 0 2px;
|
||||
}
|
||||
.company-hero h1 { margin: 6px 0 5px; font-size: clamp(31px, 3.5vw, 44px); line-height: 1.02; letter-spacing: -.045em; }
|
||||
.company-hero p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||
.company-hero-actions { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.company-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
padding: 0 0 1px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.company-tabs button {
|
||||
position: relative;
|
||||
min-height: 43px;
|
||||
padding: 9px 13px;
|
||||
border: 0;
|
||||
color: #566178;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.company-tabs button:hover { color: var(--ink); }
|
||||
.company-tabs button.active { color: var(--blue); }
|
||||
.company-tabs button.active::after { content: ''; position: absolute; left: 10px; right: 10px; bottom: -1px; height: 2px; border-radius: 2px; background: var(--blue); }
|
||||
|
||||
.company-stack { display: grid; gap: 15px; }
|
||||
.company-card { padding: 23px; }
|
||||
.company-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 18px; }
|
||||
.company-card-heading h2 { margin: 4px 0 4px; }
|
||||
.company-card-heading p { max-width: 680px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.company-card-actions { display: flex; justify-content: flex-end; margin-top: 17px; }
|
||||
|
||||
.company-data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid var(--line); border-radius: 11px; background: var(--line); }
|
||||
.company-data-grid > div { min-height: 78px; padding: 15px 16px; background: #fff; }
|
||||
.company-data-grid small, .company-data-grid strong { display: block; }
|
||||
.company-data-grid small { margin-bottom: 7px; color: var(--muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.company-data-grid strong { font-size: 13px; font-weight: 750; }
|
||||
.company-edit-form { display: grid; gap: 16px; }
|
||||
|
||||
.company-area-list { display: grid; gap: 8px; }
|
||||
.company-area-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 62px; padding: 12px 14px; border: 1px solid #e3e7ee; border-radius: 10px; background: #fafbfc; }
|
||||
.company-area-row > a { min-width: 0; text-decoration: none; }
|
||||
.company-area-row > a:hover strong { color: var(--blue); }
|
||||
.company-area-row strong, .company-area-row small { display: block; }
|
||||
.company-area-row strong { font-size: 13px; }
|
||||
.company-area-row small { margin-top: 4px; color: var(--muted); font-size: 10px; }
|
||||
.company-area-row > div { display: flex; align-items: center; gap: 7px; }
|
||||
.company-inline-action { margin-top: 13px; border: 1px dashed #cfd7e5; border-radius: 10px; background: #fbfcfe; }
|
||||
.company-inline-action summary { display: flex; align-items: center; gap: 7px; padding: 13px 14px; cursor: pointer; color: var(--blue); font-size: 12px; font-weight: 750; list-style: none; }
|
||||
.company-inline-action summary::-webkit-details-marker { display: none; }
|
||||
.company-inline-action form { display: grid; gap: 13px; padding: 0 14px 14px; }
|
||||
|
||||
.company-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
||||
.company-metrics > div { display: grid; gap: 6px; padding: 16px; border: 1px solid #e2e7ef; border-radius: 11px; background: #f8faff; }
|
||||
.company-metrics small { color: var(--muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.company-metrics strong { color: #173b7a; font-size: 25px; line-height: 1; letter-spacing: -.04em; }
|
||||
|
||||
.company-doc-heading { align-items: center; }
|
||||
.company-doc-counts { display: flex; gap: 7px; }
|
||||
.company-doc-counts span { padding: 6px 9px; border-radius: 999px; color: #536078; background: #f0f3f8; font-size: 10px; font-weight: 750; }
|
||||
.company-document-search { display: flex; align-items: center; gap: 9px; width: 100%; margin-bottom: 17px; padding: 0 12px; border: 1px solid #d8dee8; border-radius: 10px; background: white; }
|
||||
.company-document-search .icon { color: #818ba0; }
|
||||
.company-document-search input { width: 100%; min-height: 43px; border: 0; outline: 0; background: transparent; font-size: 12px; }
|
||||
.company-doc-table table { min-width: 870px; }
|
||||
.company-doc-table td { vertical-align: middle; }
|
||||
|
||||
.company-document-list { display: grid; gap: 8px; }
|
||||
.company-document-list > div { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 13px 14px; border: 1px solid #e2e6ed; border-radius: 10px; background: #fbfcfd; }
|
||||
.company-document-list strong, .company-document-list small { display: block; }
|
||||
.company-document-list strong { font-size: 12px; }
|
||||
.company-document-list small { margin-top: 4px; color: var(--muted); font-size: 10px; }
|
||||
.company-more { margin-top: 13px; }
|
||||
.company-more summary { cursor: pointer; color: #59657b; font-size: 11px; font-weight: 750; }
|
||||
.company-identifier-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; margin-top: 10px; }
|
||||
.company-identifier-list > div { display: grid; gap: 3px; padding: 11px 12px; border: 1px solid var(--line); border-radius: 9px; background: #fafbfc; }
|
||||
.company-identifier-list strong { font-size: 10px; color: var(--blue); }
|
||||
.company-identifier-list span { font-size: 12px; font-weight: 750; }
|
||||
.company-identifier-list small { color: var(--muted); font-size: 9px; }
|
||||
.company-advanced-link { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 12px 15px; border: 1px dashed #ccd4e2; border-radius: 10px; color: var(--muted); background: rgba(255,255,255,.55); font-size: 11px; }
|
||||
.company-advanced-link a { color: var(--blue); font-weight: 750; text-decoration: none; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.company-hero { align-items: flex-start; flex-direction: column; }
|
||||
.company-hero-actions { width: 100%; justify-content: space-between; }
|
||||
.company-card { padding: 18px; }
|
||||
.company-card-heading { align-items: flex-start; flex-direction: column; }
|
||||
.company-data-grid, .company-metrics { grid-template-columns: 1fr; }
|
||||
.company-area-row { align-items: flex-start; flex-direction: column; }
|
||||
.company-area-row > div { width: 100%; justify-content: space-between; }
|
||||
.company-doc-counts { width: 100%; }
|
||||
.company-advanced-link { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
.territory-page { display: grid; gap: 18px; }
|
||||
.territory-breadcrumb { margin-bottom: 0; }
|
||||
|
||||
.territory-hero {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 4px 0 2px;
|
||||
}
|
||||
.territory-hero h1 { margin: 6px 0 5px; font-size: clamp(31px, 3.5vw, 44px); line-height: 1.02; letter-spacing: -.045em; }
|
||||
.territory-hero p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||
.territory-hero-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
.territory-purpose-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid #dbe4f2;
|
||||
border-radius: 10px;
|
||||
color: #536078;
|
||||
background: #f7f9fd;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.territory-purpose-note .icon { color: var(--blue); flex: 0 0 auto; }
|
||||
|
||||
.territory-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
padding: 0 0 1px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.territory-tabs button {
|
||||
position: relative;
|
||||
min-height: 43px;
|
||||
padding: 9px 13px;
|
||||
border: 0;
|
||||
color: #566178;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.territory-tabs button:hover { color: var(--ink); }
|
||||
.territory-tabs button.active { color: var(--blue); }
|
||||
.territory-tabs button.active::after { content: ''; position: absolute; left: 10px; right: 10px; bottom: -1px; height: 2px; border-radius: 2px; background: var(--blue); }
|
||||
|
||||
.territory-stack { display: grid; gap: 15px; }
|
||||
.territory-card { padding: 22px; }
|
||||
.territory-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
|
||||
.territory-card-heading h2 { margin: 4px 0; }
|
||||
.territory-card-heading p { max-width: 680px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.territory-card-actions { display: flex; justify-content: flex-end; margin-top: 14px; }
|
||||
|
||||
.territory-data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid var(--line); border-radius: 11px; background: var(--line); }
|
||||
.territory-data-grid > div { min-height: 74px; padding: 15px 16px; background: #fff; }
|
||||
.territory-data-grid > div.wide { grid-column: 1 / -1; }
|
||||
.territory-data-grid small, .territory-data-grid strong { display: block; }
|
||||
.territory-data-grid small { margin-bottom: 7px; color: var(--muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.territory-data-grid strong { font-size: 13px; font-weight: 750; line-height: 1.45; }
|
||||
.territory-edit-form { display: grid; gap: 15px; }
|
||||
|
||||
.territory-child-list, .territory-company-list { display: grid; gap: 8px; }
|
||||
.territory-child-row, .territory-company-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 60px;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid #e2e7ef;
|
||||
border-radius: 10px;
|
||||
color: inherit;
|
||||
background: #fbfcfd;
|
||||
text-decoration: none;
|
||||
}
|
||||
.territory-child-row:hover, .territory-company-row:hover { border-color: #bfd0ee; background: #f7faff; }
|
||||
.territory-child-row > span:nth-child(2), .territory-company-row > span:first-child { flex: 1; min-width: 0; }
|
||||
.territory-child-row strong, .territory-child-row small, .territory-company-row strong, .territory-company-row small { display: block; }
|
||||
.territory-child-row strong, .territory-company-row strong { font-size: 12px; }
|
||||
.territory-child-row small, .territory-company-row small { margin-top: 4px; color: var(--muted); font-size: 10px; }
|
||||
.territory-child-row > .icon:last-child, .territory-company-row > .icon:last-child { color: #8b96aa; }
|
||||
|
||||
.territory-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
||||
.territory-metrics > div { display: grid; gap: 6px; padding: 16px; border: 1px solid #e2e7ef; border-radius: 11px; background: #f8faff; }
|
||||
.territory-metrics small { color: var(--muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.territory-metrics strong { color: #173b7a; font-size: 25px; line-height: 1; letter-spacing: -.04em; }
|
||||
|
||||
.territory-advanced-link { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 12px 15px; border: 1px dashed #ccd4e2; border-radius: 10px; color: var(--muted); background: rgba(255,255,255,.55); font-size: 11px; }
|
||||
.territory-advanced-link a { color: var(--blue); font-weight: 750; text-decoration: none; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.territory-hero { align-items: flex-start; flex-direction: column; }
|
||||
.territory-hero-actions { width: 100%; }
|
||||
.territory-card { padding: 18px; }
|
||||
.territory-card-heading { align-items: flex-start; flex-direction: column; }
|
||||
.territory-data-grid, .territory-metrics { grid-template-columns: 1fr; }
|
||||
.territory-data-grid > div.wide { grid-column: auto; }
|
||||
.territory-child-row { align-items: flex-start; flex-wrap: wrap; }
|
||||
.territory-child-row .status-badge { margin-left: 44px; }
|
||||
.territory-advanced-link { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
Reference in New Issue
Block a user