Compare commits

..
Author SHA1 Message Date
admin 7ae79af595 chore(db): scope hierarchy reset to live production 2026-09-11 00:00:23 -03:00
admin f2754d87ed chore(db): reset operational hierarchy data 2026-09-10 23:59:32 -03:00
admin dea9358451 fix(android): Acta → Hallazgo → Inventario en campo
Simplifica el flujo operativo móvil, corrige el alta desde el Yacimiento congelado, permite crear Actas antes de seleccionar Inventario y precarga Marca, Modelo, Capacidad, Número de serie y Función en Instalaciones/Subinstalaciones. Android 0.16.0.
2026-09-10 23:13:06 -03:00
admin 9fffafc447 Merge pull request #42 from enlineawork/fix/inspection-context-preventive-scope
fix: congelar contexto de inspección y corregir preventivos
2026-09-10 21:46:06 -03:00
admin 466fb29089 test(api): align inventory search contract with preventive scope 2026-09-10 21:38:16 -03:00
admin b23d7736c0 test(api): cover immutable context and scoped preventive inventory 2026-09-10 21:34:55 -03:00
admin 5b1593ed78 test(api): reject inspection context changes after creation 2026-09-10 21:34:42 -03:00
admin ff7706497d test(api): align F6.1 with immutable inspection context 2026-09-10 21:34:28 -03:00
admin cb002c60f9 fix(web): freeze inspection context and scope preventive selector 2026-09-10 21:34:09 -03:00
admin 74b91d0c8a feat(web): load preventive inventory from frozen scope 2026-09-10 21:33:01 -03:00
admin 53c8f2b4ab feat(db): enforce immutable inspection context 2026-09-10 21:32:49 -03:00
admin b7baea652b fix(api): freeze inspection context after creation 2026-09-10 21:32:39 -03:00
admin 9e6d9e45cb feat(api): register preventive candidate endpoint 2026-09-10 21:32:29 -03:00
admin 82b587ea6a feat(api): expose scoped preventive candidates 2026-09-10 21:32:18 -03:00
admin 88e7e8de65 feat(api): scope preventive candidates to inspection yacimiento 2026-09-10 21:32:12 -03:00
admin b265769047 Merge pull request #41 from enlineawork/fix/field-briefing-planned-date
fix: corregir fecha del briefing de campo
2026-09-10 21:07:28 -03:00
admin 9a40f5cbd0 test(api): cover pg Date in field briefing 2026-09-10 20:59:53 -03:00
admin ce579d1667 fix(api): normalize field briefing planned date 2026-09-10 20:59:42 -03:00
admin bc6d4604e3 Merge pull request #40 from enlineawork/fix/csrf-layout-document-pdf-projection
fix: CSRF de planificación, shell simple y proyección PDF
2026-09-10 20:31:17 -03:00
admin 5c69892815 test: keep isolated F6.2 contract independent from docs 2026-09-10 20:26:31 -03:00
admin c1eeb5f078 test: protect CSRF fix, simplified shell and PDF projection 2026-09-10 20:20:47 -03:00
admin 324a73d35c docs: define provisional PDF contract for Actas and INF 2026-09-10 20:20:33 -03:00
admin 97f34a471f docs: align document model with multi-Act inspections and provisional PDFs 2026-09-10 20:20:16 -03:00
admin a5d954b4c7 feat: show provisional Informe PDF projection 2026-09-10 20:19:59 -03:00
admin a80ef4477c feat: show provisional Acta PDF projection 2026-09-10 20:19:28 -03:00
admin b21810f166 feat: expose provisional PDF data contract for Actas and Informes 2026-09-10 20:18:44 -03:00
admin 64486f40f6 ux: stop persisting hidden operational filters 2026-09-10 20:16:59 -03:00
admin a407bf46c5 ux: remove duplicated global operational context bar 2026-09-10 20:16:40 -03:00
admin bfb7eed608 fix: use authenticated CSRF-aware request for inspection planning 2026-09-10 20:16:21 -03:00
admin c74fd80620 Merge pull request #39 from enlineawork/feat/structured-inventory-navigation
feat: navegación estructurada de Inventarios
2026-09-10 20:00:28 -03:00
35 changed files with 1383 additions and 357 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 24
versionName = "0.15.2"
versionCode = 25
versionName = "0.16.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -168,10 +168,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Elegí si el Acta es urgente o no urgente."
return
}
if (asset == null) {
error = "Seleccioná primero una Instalación o Subinstalación para iniciar el Acta."
return
}
if (acts.any { it.status == "DRAFT" }) {
error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente."
return
@@ -179,7 +175,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy {
val created = actsRepository.create(
currentVisit.id,
asset.id,
asset?.id,
currentVisit.code,
urgency,
)
@@ -188,17 +184,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
loadActsInternal(currentVisit.id, selectDraft = false)
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta."
if (selectedFieldAsset?.capture?.readyForFinding == true) {
if (asset != null && selectedFieldAsset?.capture?.readyForFinding == true) {
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
}
}
}
fun createAct(urgency: String = "NON_URGENT") = createActForSelectedInventory(urgency)
fun searchInventory(search: String, parentId: String? = null) {
val currentVisit = visit ?: return
launchBusy {
val effectiveParentId = parentId ?: inventoryParentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
inventory = repository.fieldInventory(currentVisit.id, search, effectiveParentId).data
inventory = repository.fieldInventory(currentVisit.id, search, parentId).data
}
}
@@ -329,7 +329,7 @@ class MobileActsRepository(context: Context) {
suspend fun create(
visitId: String,
assetId: String,
assetId: String? = null,
visitCode: String,
urgency: String = "NON_URGENT",
): MobileActDetail = authorized { session ->
@@ -341,7 +341,7 @@ class MobileActsRepository(context: Context) {
urgency = urgency,
title = "Acta de inspección $visitCode",
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
assetIds = listOf(assetId),
assetIds = listOfNotNull(assetId),
),
)
}
@@ -86,7 +86,10 @@ fun F3VisitRoot(model: MainViewModel) {
inventoryMode = true
},
)
inventoryMode -> F3FieldInventoryScreen(model, onBack = { inventoryMode = false })
inventoryMode -> F3FieldInventoryScreen(model, onBack = {
inventoryMode = false
actsMode = true
})
else -> F3VisitOverview(
model = model,
onInventory = { inventoryMode = true },
@@ -119,6 +122,9 @@ private fun F3VisitOverview(
}
Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}")
visit.scopeAsset?.let {
Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
}
visit.plannedStartAt?.let {
Text("Planificada: ${f3ShortDate(it)}", style = MaterialTheme.typography.bodySmall)
}
@@ -138,10 +144,10 @@ private fun F3VisitOverview(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("La inspección todavía no comenzó", fontWeight = FontWeight.Bold)
Text("Iniciarla registra fecha/hora real y tu usuario como actor de campo.")
Text("Todo listo para comenzar", fontWeight = FontWeight.Bold)
Text("Al iniciar, pasás directamente a las Actas de esta inspección.")
Button(
onClick = { model.startVisit() },
onClick = { model.startVisit(); onActs() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") }
@@ -150,12 +156,12 @@ private fun F3VisitOverview(
}
if (visit.status == "IN_PROGRESS") {
Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("Abrir Inventario de campo")
}
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
Button(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" }
Text("Actas de la inspección · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
Text("Abrir Actas · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
}
OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("Inventario / Hallazgos")
}
} else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
@@ -163,7 +169,7 @@ private fun F3VisitOverview(
}
} else if (visit.status == "PLANNED") {
Text(
"Primero iniciá la inspección para habilitar altas, fotografías, Actas y Hallazgos.",
"Primero iniciá la inspección para habilitar Actas, Hallazgos y altas de campo.",
style = MaterialTheme.typography.bodySmall,
)
}
@@ -209,7 +215,9 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
var search by rememberSaveable(visit.id) { mutableStateOf("") }
var showCreate by rememberSaveable(visit.id) { mutableStateOf(false) }
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("Área de la inspección") }
var parentLabel by rememberSaveable(visit.id) {
mutableStateOf(visit.scopeAsset?.name ?: "Yacimiento de la inspección")
}
var name by rememberSaveable(visit.id) { mutableStateOf("") }
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
@@ -323,10 +331,10 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onBack) { Text("Volver") }
OutlinedButton(onClick = onBack) { Text("Acta") }
Column(horizontalAlignment = Alignment.End) {
Text("Inventario de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Área → Yacimiento → Instalación Subinstalación", style = MaterialTheme.typography.bodySmall)
Text("Nuevo Hallazgo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Elegí una Instalación o Subinstalación", style = MaterialTheme.typography.bodySmall)
}
}
Column(Modifier.padding(horizontal = 16.dp)) { F3MessageStrip(model) }
@@ -410,170 +418,182 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
}
}
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar por nombre o código") },
modifier = Modifier.weight(1f),
singleLine = true,
)
Spacer(Modifier.width(8.dp))
Button(onClick = { model.searchInventory(search) }, enabled = !model.busy) { Text("Buscar") }
}
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text("Alta en campo", fontWeight = FontWeight.Bold)
Text("Padre: $parentLabel", style = MaterialTheme.typography.bodySmall)
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("¿Dónde encontraste el Hallazgo?", fontWeight = FontWeight.Bold)
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar instalación o subinstalación") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { model.searchInventory(search) },
enabled = !model.busy,
modifier = Modifier.weight(1f),
) { Text("Buscar") }
OutlinedButton(
onClick = {
showCreate = !showCreate
if (showCreate) model.loadFieldTypes(parentId)
},
modifier = Modifier.weight(1f),
) { Text(if (showCreate) "Cancelar" else "+ Agregar") }
}
}
OutlinedButton(onClick = {
showCreate = !showCreate
if (showCreate) model.loadFieldTypes(parentId)
}) { Text(if (showCreate) "Ocultar" else "Agregar") }
}
if (showCreate) {
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState())
.weight(1f, fill = false),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (parentId != null) {
OutlinedButton(onClick = {
parentId = null
parentLabel = "Área de la inspección"
resetCreateForm()
selectedTypeId = null
model.loadFieldTypes(null)
}) { Text("Volver al Área") }
}
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) {
Column(
Modifier
.fillMaxWidth()
.padding(12.dp)
.verticalScroll(rememberScrollState())
.weight(1f, fill = false),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Nueva alta de campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Ubicación: $parentLabel", style = MaterialTheme.typography.bodySmall)
if (parentId != null) {
OutlinedButton(onClick = {
parentId = null
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
resetCreateForm()
selectedTypeId = null
model.loadFieldTypes(null)
}) { Text("Volver al Yacimiento") }
}
if (model.fieldTypes.isEmpty()) {
Text("Este nivel no admite más hijos estructurales.")
} else {
Text("Vas a crear", style = MaterialTheme.typography.bodySmall)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(model.fieldTypes, key = { it.id }) { type ->
AssistChip(
onClick = {
selectedTypeId = type.id
selectedFamilyId = null
attributeValues.clear()
},
label = { Text(if (type.id == selectedTypeId) "${type.name}" else type.name) },
)
if (model.fieldTypes.isEmpty()) {
Text("No hay un tipo disponible para esta ubicación.")
} else {
Text("Tipo de registro", style = MaterialTheme.typography.bodySmall)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(model.fieldTypes, key = { it.id }) { type ->
AssistChip(
onClick = {
selectedTypeId = type.id
selectedFamilyId = null
attributeValues.clear()
},
label = { Text(if (type.id == selectedTypeId) "${type.name}" else type.name) },
)
}
}
}
}
if (selectedType?.familyRequired == true) {
Text("Familia técnica", fontWeight = FontWeight.Bold)
Text(
"Elegí la que corresponda al Excel. Si no existe, usá Otro / no catalogado; nunca quedás bloqueado.",
style = MaterialTheme.typography.bodySmall,
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(selectedType.families, key = { it.id }) { family ->
AssistChip(
onClick = { selectedFamilyId = family.id },
label = {
val prefix = when {
selectedFamilyId == family.id -> ""
family.isOther -> "+ "
else -> ""
}
Text(prefix + family.name)
},
)
if (selectedType?.familyRequired == true) {
Text("Clasificación", fontWeight = FontWeight.Bold)
Text(
"Elegí el tipo técnico. Si no está catalogado, usá Otro / no catalogado.",
style = MaterialTheme.typography.bodySmall,
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(selectedType.families, key = { it.id }) { family ->
AssistChip(
onClick = { selectedFamilyId = family.id },
label = {
val prefix = when {
selectedFamilyId == family.id -> ""
family.isOther -> "+ "
else -> ""
}
Text(prefix + family.name)
},
)
}
}
selectedFamily?.let { family ->
if (family.isOther) {
Text(
"Se registrará como no catalogado para revisión posterior en oficina.",
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall,
)
}
if (family.informationLabels.isNotEmpty()) {
Text(
"Información esperada: ${family.informationLabels.joinToString(" · ")}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
selectedFamily?.let { family ->
if (family.isOther) {
Text(
"Se registrará como familia no catalogada para revisión posterior en oficina.",
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall,
)
}
if (family.informationLabels.isNotEmpty()) {
Text(
"Información esperada: ${family.informationLabels.joinToString(" · ")}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nombre identificable *") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = commonName,
onValueChange = { commonName = it },
label = { Text("Nombre habitual") },
modifier = Modifier.fillMaxWidth(),
)
selectedType?.attributes?.forEach { definition ->
OutlinedTextField(
value = attributeValues[definition.code].orEmpty(),
onValueChange = { attributeValues[definition.code] = it },
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
supportingText = {
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
if (details.isNotBlank()) Text(details)
},
keyboardOptions = KeyboardOptions(
keyboardType = if (
definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")
) KeyboardType.Decimal else KeyboardType.Text,
),
value = name,
onValueChange = { name = it },
label = { Text("Nombre o código identificable *") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = commonName,
onValueChange = { commonName = it },
label = { Text("Nombre habitual") },
modifier = Modifier.fillMaxWidth(),
)
}
val attributesReady = selectedType?.attributes
?.filter { it.isRequired }
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
?: false
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button(
onClick = {
if (f3HasLocation(context)) createWithLocation()
else locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
selectedType?.attributes?.forEach { definition ->
OutlinedTextField(
value = attributeValues[definition.code].orEmpty(),
onValueChange = { attributeValues[definition.code] = it },
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
supportingText = {
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
if (details.isNotBlank()) Text(details)
},
keyboardOptions = KeyboardOptions(
keyboardType = if (
definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")
) KeyboardType.Decimal else KeyboardType.Text,
),
modifier = Modifier.fillMaxWidth(),
)
},
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Capturar GPS y crear") }
HorizontalDivider()
}
val attributesReady = selectedType?.attributes
?.filter { it.isRequired }
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
?: false
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button(
onClick = {
if (f3HasLocation(context)) createWithLocation()
else locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
},
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar alta y capturar GPS") }
}
}
}
Text("Estructura disponible", modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), fontWeight = FontWeight.Bold)
Text(
"Instalaciones y subinstalaciones",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
fontWeight = FontWeight.Bold,
)
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(model.inventory, key = { it.id }) { item ->
items(
model.inventory.filter { candidate ->
candidate.type?.let(::f3TypeCode) in setOf("instalacion", "subinstalacion")
},
key = { it.id },
) { item ->
F3InventoryCard(
item = item,
onInspect = { model.selectExisting(item) },
@@ -611,10 +631,10 @@ private fun F3CaptureCard(
if (captureRequired) {
Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
if (!ready) {
Text("Antes de registrar Hallazgos, completá GPS + foto.", color = MaterialTheme.colorScheme.error)
Text("Antes de registrar el Hallazgo, completá GPS + foto.", color = MaterialTheme.colorScheme.error)
Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto obligatoria") }
} else {
Text("Captura completa · listo para Hallazgos", color = MaterialTheme.colorScheme.primary)
Text("Captura completa · listo para el Hallazgo", color = MaterialTheme.colorScheme.primary)
}
} else {
Text("Registro existente seleccionado.", style = MaterialTheme.typography.bodySmall)
@@ -631,8 +651,8 @@ private fun F3InventoryCard(
) {
val typeCode = item.type?.let(::f3TypeCode).orEmpty()
val canHaveFinding = typeCode in setOf("instalacion", "subinstalacion")
val canHaveChild = typeCode in setOf("yacimiento", "instalacion")
val childLabel = if (typeCode == "yacimiento") "Agregar instalación aquí" else "Agregar subinstalación aquí"
val canHaveChild = typeCode == "instalacion"
val childLabel = "+ Agregar subinstalación"
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
@@ -646,12 +666,12 @@ private fun F3InventoryCard(
}
if (canHaveFinding) {
Text(
if (item.readyForFinding) "Disponible para Hallazgos" else "GPS/foto pendiente",
if (item.readyForFinding) "Disponible" else "GPS/foto pendiente",
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
OutlinedButton(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Abrir Hallazgos" else "Usar en esta inspección")
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Seleccionar para Hallazgo" else "Usar para Hallazgo")
}
}
if (canHaveChild) {
@@ -126,13 +126,14 @@ fun MobileActsScreen(
Text("Actas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
Text("${visit.code} · ${visit.operatorCompany?.name.orEmpty()}")
visit.scopeAsset?.let { Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodySmall) }
F32ActMessage(model)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Actas de esta Inspección", fontWeight = FontWeight.Bold)
Text("Actas de esta inspección", fontWeight = FontWeight.Bold)
if (model.acts.isEmpty()) {
Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.")
Text("Todavía no hay Actas. Creá la primera y después agregá los Hallazgos.")
}
model.acts.forEach { act ->
val active = selected?.id == act.id
@@ -156,15 +157,15 @@ fun MobileActsScreen(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Nueva Acta", fontWeight = FontWeight.Bold)
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (model.acts.any { it.status == "LOCKED" }) {
Text(
"Podés abrir una nueva Acta aunque otra esté BLOQUEADA esperando firmas. Sólo se permite un borrador a la vez.",
style = MaterialTheme.typography.bodySmall,
)
}
Text("Urgencia del Acta", fontWeight = FontWeight.Bold)
Text("Urgencia", fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(
onClick = { newActUrgency = "NON_URGENT" },
@@ -183,20 +184,15 @@ fun MobileActsScreen(
},
style = MaterialTheme.typography.bodySmall,
)
val selectedInventory = model.selectedFieldAsset?.asset
if (selectedInventory == null) {
Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
Text("Ir a Inventario y elegir")
}
} else {
Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}")
Button(
onClick = { model.createActForSelectedInventory(newActUrgency) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") }
}
Text(
"Abrí el Acta primero. Después elegís la Instalación o Subinstalación al agregar cada Hallazgo.",
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = { model.createAct(newActUrgency) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") }
}
}
}
@@ -220,7 +216,21 @@ fun MobileActsScreen(
when (selected.status) {
"DRAFT" -> {
Text("1. Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Buscá una Instalación o Subinstalación existente. Si no está, podés agregarla en campo en el mismo flujo.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
Text("+ Agregar Hallazgo")
}
}
}
HorizontalDivider()
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") })
AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") })
@@ -259,12 +269,7 @@ fun MobileActsScreen(
}
HorizontalDivider()
Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Los Hallazgos se cargan desde Inventario y quedan vinculados explícitamente a ${selected.code}. El Acta también puede finalizar sin Hallazgos cuando corresponde dejar constancia de una inspección o verificación sin nuevos incumplimientos.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") }
HorizontalDivider()
Text("3. Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.")
Button(
onClick = { model.prepareSelectedAct() },
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test
fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(24, BuildConfig.VERSION_CODE)
assertEquals("0.15.2-debug", BuildConfig.VERSION_NAME)
assertEquals(25, BuildConfig.VERSION_CODE)
assertEquals("0.16.0-debug", BuildConfig.VERSION_NAME)
}
@Test
@@ -36,6 +36,20 @@ export interface FieldBriefingAct {
findings: FieldBriefingFinding[];
}
function toIsoDate(value: unknown): string {
const parsed = value instanceof Date
? value
: value == null
? new Date()
: new Date(String(value));
if (Number.isNaN(parsed.getTime())) {
throw new Error('La fecha planificada de la inspección no es válida');
}
return parsed.toISOString().slice(0, 10);
}
@Injectable()
export class FieldBriefingService {
constructor(private readonly dataSource: DataSource) {}
@@ -67,7 +81,9 @@ export class FieldBriefingService {
});
}
const plannedOn = String(visit.plannedStartAt ?? new Date().toISOString()).slice(0, 10);
// PostgreSQL/pg entrega los timestamptz como Date. String(date).slice(0, 10)
// produce textos como "Thu Sep 17", que PostgreSQL rechaza al castear a date.
const plannedOn = toIsoDate(visit.plannedStartAt);
const rows = (await this.dataSource.query(`
WITH prior_acts AS (
@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F62FreezeInspectionContext1790098200000 implements MigrationInterface {
name = 'F62FreezeInspectionContext1790098200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION prevent_inspection_context_mutation()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.operational_area_id IS DISTINCT FROM OLD.operational_area_id
OR NEW.scope_asset_id IS DISTINCT FROM OLD.scope_asset_id
OR NEW.operator_company_id IS DISTINCT FROM OLD.operator_company_id THEN
RAISE EXCEPTION USING
ERRCODE='23514',
MESSAGE='El Área, Yacimiento y Operadora de una Inspección quedan fijos desde su creación';
END IF;
RETURN NEW;
END $$
`);
await queryRunner.query(`
DROP TRIGGER IF EXISTS trg_inspection_context_immutable ON inspection_visits
`);
await queryRunner.query(`
CREATE TRIGGER trg_inspection_context_immutable
BEFORE UPDATE OF operational_area_id, scope_asset_id, operator_company_id
ON inspection_visits
FOR EACH ROW EXECUTE FUNCTION prevent_inspection_context_mutation()
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP TRIGGER IF EXISTS trg_inspection_context_immutable ON inspection_visits
`);
await queryRunner.query('DROP FUNCTION IF EXISTS prevent_inspection_context_mutation()');
}
}
@@ -0,0 +1,57 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F63MobileFieldCommonAttributes1790099100000 implements MigrationInterface {
name = 'F63MobileFieldCommonAttributes1790099100000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
WITH target_types AS (
SELECT id
FROM asset_types
WHERE lower(code) IN ('instalacion','subinstalacion')
), fields(code,name,sort_order) AS (
VALUES
('campo_marca','Marca',10),
('campo_modelo','Modelo',20),
('campo_capacidad','Capacidad',30),
('campo_numero_serie','Número de serie',40),
('campo_funcion','Función',50)
)
INSERT INTO asset_attribute_definitions (
asset_type_id, code, name, data_type, is_required, is_active, sort_order
)
SELECT target.id, fields.code, fields.name, 'TEXT'::asset_attribute_data_type,
false, true, fields.sort_order
FROM target_types target
CROSS JOIN fields
WHERE NOT EXISTS (
SELECT 1
FROM asset_attribute_definitions existing
WHERE existing.asset_type_id=target.id
AND lower(existing.code)=lower(fields.code)
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM asset_attribute_values value
USING asset_attribute_definitions definition, asset_types type
WHERE value.definition_id=definition.id
AND definition.asset_type_id=type.id
AND lower(type.code) IN ('instalacion','subinstalacion')
AND definition.code IN (
'campo_marca','campo_modelo','campo_capacidad','campo_numero_serie','campo_funcion'
)
`);
await queryRunner.query(`
DELETE FROM asset_attribute_definitions definition
USING asset_types type
WHERE definition.asset_type_id=type.id
AND lower(type.code) IN ('instalacion','subinstalacion')
AND definition.code IN (
'campo_marca','campo_modelo','campo_capacidad','campo_numero_serie','campo_funcion'
)
`);
}
}
@@ -0,0 +1,240 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const TARGET_CODES = [
'departamento',
'area',
'yacimiento',
'instalacion',
'subinstalacion',
] as const;
const DELETE_ORDER = [
'subinstalacion',
'instalacion',
'yacimiento',
'area',
'departamento',
] as const;
type ProtectedSnapshot = {
users: string;
companies: string;
companyProfiles: string;
assetTypes: string;
assetAttributes: string;
inventoryFamilies: string;
familyAttributes: string;
findingCategories: string;
findingItems: string;
};
export class ResetOperationalHierarchyData1790099200000 implements MigrationInterface {
name = 'ResetOperationalHierarchyData1790099200000';
public async up(queryRunner: QueryRunner): Promise<void> {
// This is a one-time live-data cleanup, not a new canonical empty seed.
// Fresh CI/bootstrap databases intentionally have no admin account while
// replaying the historical migration chain, so they must retain the F6.1
// presentation seed used by hierarchy/planning contract tests.
const adminRows = (await queryRunner.query(`
SELECT id
FROM users
WHERE lower(btrim(username))='admin'
ORDER BY id
`)) as Array<{ id: string }>;
if (adminRows.length === 0) {
// eslint-disable-next-line no-console
console.log('[hierarchy-reset] skipped: no live admin account on migration replay');
return;
}
if (adminRows.length !== 1) {
throw new Error(
`Hierarchy reset aborted: expected exactly one live admin account, found ${adminRows.length}`,
);
}
const targetTypes = (await queryRunner.query(
`
SELECT lower(code) AS code
FROM asset_types
WHERE lower(code)=ANY($1::text[])
ORDER BY lower(code)
`,
[[...TARGET_CODES]],
)) as Array<{ code: string }>;
const found = new Set(targetTypes.map((row) => row.code));
const missing = TARGET_CODES.filter((code) => !found.has(code));
if (missing.length > 0) {
throw new Error(`Hierarchy reset aborted: missing asset types ${missing.join(', ')}`);
}
const before = await this.protectedSnapshot(queryRunner);
await queryRunner.query(
`
CREATE TEMP TABLE reset_target_assets ON COMMIT DROP AS
SELECT asset.id
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE lower(type.code)=ANY($1::text[])
`,
[[...TARGET_CODES]],
);
await queryRunner.query(`CREATE UNIQUE INDEX reset_target_assets_pk ON reset_target_assets(id)`);
const [targetCount] = (await queryRunner.query(
`SELECT COUNT(*)::integer AS total FROM reset_target_assets`,
)) as Array<{ total: number }>;
// An Inspection freezes Area/Yacimiento/Operadora from creation and several
// inspection tables hold RESTRICT references to the hierarchy. Keeping a
// transaction that points to deleted territory would be invalid, so the
// complete disposable inspection graph is cleared first.
await queryRunner.query('TRUNCATE TABLE inspection_visits CASCADE');
// Legacy administrative departments are also presentation/operational data.
// Current F6 Departments live in assets, but this prevents old rows from
// resurfacing through compatibility paths.
await queryRunner.query('TRUNCATE TABLE administrative_departments CASCADE');
// Legal-right participants depend on area_legal_rights rather than directly
// on assets. Remove them before the generic direct-FK cleanup below.
await queryRunner.query(`
DELETE FROM area_legal_right_organizations organization
USING area_legal_rights legal_right
WHERE organization.right_id=legal_right.id
AND legal_right.area_id IN (SELECT id FROM reset_target_assets)
`);
// Clean every table that directly references one of the hierarchy assets.
// This deliberately discovers the current schema instead of maintaining a
// fragile hand-written list as new dossier/history tables are added.
await queryRunner.query(`
DO $$
DECLARE dependency record;
BEGIN
FOR dependency IN
SELECT
namespace.nspname AS schema_name,
relation.relname AS table_name,
attribute.attname AS column_name
FROM pg_constraint constraint_row
JOIN pg_class relation ON relation.oid=constraint_row.conrelid
JOIN pg_namespace namespace ON namespace.oid=relation.relnamespace
JOIN LATERAL unnest(constraint_row.conkey) WITH ORDINALITY local_key(attnum,ordinality)
ON true
JOIN LATERAL unnest(constraint_row.confkey) WITH ORDINALITY referenced_key(attnum,ordinality)
ON referenced_key.ordinality=local_key.ordinality
JOIN pg_attribute attribute
ON attribute.attrelid=constraint_row.conrelid
AND attribute.attnum=local_key.attnum
JOIN pg_attribute referenced_attribute
ON referenced_attribute.attrelid=constraint_row.confrelid
AND referenced_attribute.attnum=referenced_key.attnum
WHERE constraint_row.contype='f'
AND constraint_row.confrelid='assets'::regclass
AND constraint_row.conrelid<>'assets'::regclass
AND array_length(constraint_row.conkey,1)=1
AND referenced_attribute.attname='id'
ORDER BY namespace.nspname,relation.relname,attribute.attname
LOOP
EXECUTE format(
'DELETE FROM %I.%I WHERE %I IN (SELECT id FROM reset_target_assets)',
dependency.schema_name,
dependency.table_name,
dependency.column_name
);
END LOOP;
END $$;
`);
// parent_id is RESTRICT, therefore physical hierarchy rows are deleted from
// the leaves upward. Company/Operator assets are intentionally not targets.
for (const code of DELETE_ORDER) {
await queryRunner.query(
`
DELETE FROM assets asset
USING asset_types type
WHERE asset.asset_type_id=type.id
AND lower(type.code)=$1
`,
[code],
);
}
const after = await this.protectedSnapshot(queryRunner);
for (const key of Object.keys(before) as Array<keyof ProtectedSnapshot>) {
if (before[key] !== after[key]) {
throw new Error(
`Hierarchy reset verification failed: protected ${key} changed (${before[key]} -> ${after[key]})`,
);
}
}
const [verification] = (await queryRunner.query(`
SELECT
(
SELECT COUNT(*)::integer
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE lower(type.code)=ANY($1::text[])
) AS hierarchy_assets,
(SELECT COUNT(*)::integer FROM administrative_departments) AS administrative_departments,
(SELECT COUNT(*)::integer FROM inspection_visits) AS inspection_visits
`, [[...TARGET_CODES]])) as Array<{
hierarchy_assets: number;
administrative_departments: number;
inspection_visits: number;
}>;
if (
!verification
|| Number(verification.hierarchy_assets) !== 0
|| Number(verification.administrative_departments) !== 0
|| Number(verification.inspection_visits) !== 0
) {
throw new Error(`Hierarchy reset verification failed: ${JSON.stringify(verification ?? {})}`);
}
// eslint-disable-next-line no-console
console.log(
`[hierarchy-reset] removed ${Number(targetCount?.total ?? 0)} Departamento/Área/Yacimiento/Instalación/Subinstalación assets; inspections and legacy departments cleared; users=${after.users}; companies=${after.companies} preserved`,
);
}
public async down(): Promise<void> {
throw new Error(
'ResetOperationalHierarchyData is intentionally destructive; restore the automatic deploy PRE database backup instead.',
);
}
private async protectedSnapshot(queryRunner: QueryRunner): Promise<ProtectedSnapshot> {
const [snapshot] = (await queryRunner.query(`
SELECT
(SELECT COUNT(*)::text FROM users) AS "users",
(
SELECT COUNT(*)::text
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE type.operational_role='COMPANY'
) AS "companies",
(
SELECT COUNT(*)::text
FROM organization_profiles profile
JOIN assets asset ON asset.id=profile.asset_id
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE type.operational_role='COMPANY'
) AS "companyProfiles",
(SELECT COUNT(*)::text FROM asset_types) AS "assetTypes",
(SELECT COUNT(*)::text FROM asset_attribute_definitions) AS "assetAttributes",
(SELECT COUNT(*)::text FROM inventory_families) AS "inventoryFamilies",
(SELECT COUNT(*)::text FROM inventory_family_attribute_definitions) AS "familyAttributes",
(SELECT COUNT(*)::text FROM finding_categories) AS "findingCategories",
(SELECT COUNT(*)::text FROM finding_catalog_items) AS "findingItems"
`)) as ProtectedSnapshot[];
if (!snapshot) throw new Error('Hierarchy reset aborted: could not snapshot protected masters');
return snapshot;
}
}
@@ -1,7 +1,6 @@
import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
ArrayUnique,
IsArray,
IsEnum,
@@ -42,7 +41,6 @@ export class CreateInspectionActDto {
observations?: string | null;
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(200)
@ArrayUnique()
@IsUUID('4', { each: true })
@@ -656,6 +656,7 @@ export class InspectionActsService {
visitId: string,
assetIds: string[],
): Promise<void> {
if (assetIds.length === 0) return;
const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS count
FROM inspection_visit_assets
@@ -663,7 +664,7 @@ export class InspectionActsService {
AND asset_id = ANY($2::uuid[])
AND included = true
`, [visitId, assetIds])) as Array<{ count: number }>;
if (assetIds.length < 1 || Number(row?.count ?? 0) !== assetIds.length) {
if (Number(row?.count ?? 0) !== assetIds.length) {
throw new BadRequestException({
code: 'INSPECTION_ACT_ASSET_INVALID',
message: 'Cada inventario del acta debe estar incluido en la inspección',
@@ -1,9 +1,9 @@
import { Transform } from 'class-transformer';
import {
IsEmpty,
IsISO8601,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
@@ -17,15 +17,15 @@ export class UpdateInspectionVisitDto {
objective?: string | null;
@IsOptional()
@IsUUID('4')
@IsEmpty({ message: 'El Yacimiento se fija al crear la Inspección y no puede modificarse' })
scopeAssetId?: string | null;
@IsOptional()
@IsUUID('4')
@IsEmpty({ message: 'El Área se fija al crear la Inspección y no puede modificarse' })
operationalAreaId?: string | null;
@IsOptional()
@IsUUID('4')
@IsEmpty({ message: 'La Operadora se fija al crear la Inspección y no puede modificarse' })
operatorCompanyId?: string | null;
@IsOptional()
@@ -61,7 +61,7 @@ export class F3FieldInventoryStructureService {
principal: AuthPrincipal,
) {
const base = await this.fieldInventory.types(visitId, parentId, principal) as unknown as FieldTypesBase;
const effectiveParentId = parentId ?? base.context.area.id;
const effectiveParentId = parentId ?? base.parent.id;
const parent = await this.parent(effectiveParentId);
const expectedTypeCode = STRUCTURAL_CHILD[parent.typeCode.toLowerCase()];
const data = expectedTypeCode
@@ -0,0 +1,17 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { InspectionPreventiveCandidatesService } from './inspection-preventive-candidates.service';
@Controller('inspection-visits')
export class InspectionPreventiveCandidatesController {
constructor(private readonly candidates: InspectionPreventiveCandidatesService) {}
@Get(':id/preventive-candidates')
@RequirePermissions('inspections.read')
list(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Query('search') search?: string,
) {
return this.candidates.list(id, search);
}
}
@@ -0,0 +1,77 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
export interface InspectionPreventiveCandidate {
id: string;
code: string;
name: string;
typeName: string;
}
@Injectable()
export class InspectionPreventiveCandidatesService {
constructor(private readonly dataSource: DataSource) {}
async list(visitId: string, search?: string): Promise<{ data: InspectionPreventiveCandidate[] }> {
const [context] = (await this.dataSource.query(`
SELECT
visit.operational_area_id AS "operationalAreaId",
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId"
FROM inspection_visits visit
WHERE visit.id = $1::uuid
`, [visitId])) as Array<{
operationalAreaId: string | null;
scopeAssetId: string | null;
}>;
if (!context) {
throw new NotFoundException({
code: 'INSPECTION_VISIT_NOT_FOUND',
message: 'Visita de inspección no encontrada',
});
}
if (!context.operationalAreaId || !context.scopeAssetId) return { data: [] };
const term = search?.trim().slice(0, 200) ?? '';
const rows = (await this.dataSource.query(`
WITH RECURSIVE scope_tree AS (
SELECT root.id
FROM assets root
WHERE root.id = $2::uuid
UNION ALL
SELECT child.id
FROM assets child
INNER JOIN scope_tree parent_scope ON parent_scope.id = child.parent_id
)
SELECT
asset.id,
asset.code,
asset.name,
asset_type.name AS "typeName"
FROM scope_tree
INNER JOIN assets asset ON asset.id = scope_tree.id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
WHERE asset.id <> $2::uuid
AND asset.operational_area_id = $3::uuid
AND asset.information_status <> 'INACTIVE'
AND lower(asset_type.code) IN ('instalacion', 'subinstalacion')
AND NOT EXISTS (
SELECT 1
FROM inspection_visit_assets linked
WHERE linked.visit_id = $1::uuid
AND linked.asset_id = asset.id
)
AND (
$4::text = ''
OR asset.code ILIKE '%' || $4::text || '%'
OR asset.name ILIKE '%' || $4::text || '%'
OR COALESCE(asset.common_name, '') ILIKE '%' || $4::text || '%'
)
ORDER BY asset_type.name, asset.name, asset.code
LIMIT 100
`, [visitId, context.scopeAssetId, context.operationalAreaId, term])) as InspectionPreventiveCandidate[];
return { data: rows };
}
}
@@ -10,17 +10,25 @@ import { FieldInventoryController } from './field-inventory.controller';
import { FieldInventoryService } from './field-inventory.service';
import { InspectionPlanningCreateService } from './inspection-planning-create.service';
import { InspectionPlanningHierarchyService } from './inspection-planning-hierarchy.service';
import { InspectionPreventiveCandidatesController } from './inspection-preventive-candidates.controller';
import { InspectionPreventiveCandidatesService } from './inspection-preventive-candidates.service';
import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service';
import { InspectionVisitsController } from './inspection-visits.controller';
import { InspectionVisitsService } from './inspection-visits.service';
@Module({
imports: [AuditModule, AssetMasterModule, InspectionFindingsModule],
controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController],
controllers: [
InspectionVisitsController,
InspectionPreventiveCandidatesController,
FieldInventoryController,
FieldFindingsController,
],
providers: [
{ provide: InspectionVisitsService, useClass: F4InspectionVisitsService },
InspectionPlanningCreateService,
InspectionPlanningHierarchyService,
InspectionPreventiveCandidatesService,
InspectionVisitLifecycleService,
FieldInventoryService,
F3FieldInventoryStructureService,
+4 -4
View File
@@ -7,16 +7,16 @@ function mountedRepoFile(path: string): string {
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
}
test('F6.1 Android test cut targets production API and has a distinct installable debug version', () => {
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 24/);
assert.match(gradle, /versionName = "0\.15\.2"/);
assert.match(gradle, /versionCode = 25/);
assert.match(gradle, /versionName = "0\.16\.0"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
});
test('F5/F6.1 field inventory exposes Other families as reviewable choices to Android', () => {
test('F5/F6.3 field inventory exposes Other families as reviewable choices to Android', () => {
const service = readFileSync(
resolve(process.cwd(), 'src/inspection-visits/f3-field-inventory-structure.service.ts'),
'utf8',
@@ -62,11 +62,17 @@ test('F6.1 operator choices are resolved at the planned timestamp', () => {
assert.match(createPage, /\[areaId, plannedStartAt\]/);
});
test('F6.1 WEB keeps Area and Yacimiento as separate concepts when editing', () => {
test('F6.1 WEB keeps Area and Yacimiento as separate concepts and freezes context after creation', () => {
const source = web('src/pages/InspectionVisitEditorF4Page.tsx');
assert.doesNotMatch(source, /<span>Área \/ Yacimiento<\/span>/);
assert.match(source, /<span>Área<\/span>/);
assert.match(source, /<span>Yacimiento<\/span>/);
assert.match(source, /scopeAssetId: visit\?\.scopeAsset\?\.id \?\? null/);
assert.match(source, /<span>Operadora<\/span>/);
assert.match(source, /visit\.operationalArea\.name[\s\S]{0,160}readOnly/);
assert.match(source, /visit\.scopeAsset\.name[\s\S]{0,160}readOnly/);
assert.match(source, /visit\.operatorCompany\.name[\s\S]{0,160}readOnly/);
assert.doesNotMatch(source, /updateInspectionVisit\(id, \{[\s\S]{0,300}scopeAssetId:/);
assert.doesNotMatch(source, /updateInspectionVisit\(id, \{[\s\S]{0,300}operationalAreaId:/);
assert.doesNotMatch(source, /updateInspectionVisit\(id, \{[\s\S]{0,300}operatorCompanyId:/);
});
@@ -75,12 +75,20 @@ test('F6.1 historical operational lists read Company and Area from the parent In
assert.doesNotMatch(reports, /context_asset\.operator_company_id/);
});
test('F6.1 WEB Inventory searches use physical Area and never the operator snapshot filter', () => {
test('F6.1 WEB Inventory searches use physical scope and never the operator snapshot filter', () => {
const inspectionEditor = source('../web-v2/src/pages/InspectionVisitEditorF4Page.tsx');
const preventiveApi = source('../web-v2/src/features/inspections/preventiveCandidatesApi.ts');
const preventiveService = source('src/inspection-visits/inspection-preventive-candidates.service.ts');
const fieldDiscoveries = source('../web-v2/src/pages/FieldDiscoveriesPage.tsx');
const inventoryMerge = source('../web-v2/src/lib/inventoryMergeApi.ts');
for (const page of [inspectionEditor, fieldDiscoveries, inventoryMerge]) {
assert.match(inspectionEditor, /listInspectionPreventiveCandidates\(id, assetSearch\)/);
assert.match(preventiveApi, /inspection-visits\/\$\{visitId\}\/preventive-candidates/);
assert.match(preventiveService, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/);
assert.doesNotMatch(preventiveService, /asset\.operator_company_id/);
assert.doesNotMatch(inspectionEditor, /listAssets\(\{[\s\S]{0,300}operatorCompanyId:/);
for (const page of [fieldDiscoveries, inventoryMerge]) {
assert.match(page, /listAssets\(\{[\s\S]{0,300}operationalAreaId:/);
assert.doesNotMatch(page, /listAssets\(\{[\s\S]{0,300}operatorCompanyId:/);
}
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.2 planning uses the shared CSRF-aware API requester', () => {
const page = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
const api = source('../web-v2/src/lib/api.ts');
assert.match(page, /import \{ apiRequest \} from '\.\.\/lib\/api';/);
assert.doesNotMatch(page, /async function requestJson/);
assert.doesNotMatch(page, /fetch\(`\/api\/v3/);
assert.match(page, /apiRequest<CreatedInspection>\('\/inspection-visits'/);
assert.match(api, /headers\.set\('x-csrf-token', token\)/);
assert.match(api, /readCsrfCookie\(\)/);
});
test('F6.2 removes the duplicated global operational bar and hidden persisted filters', () => {
const layout = source('../web-v2/src/layout/AppLayout.tsx');
const context = source('../web-v2/src/context/OperationalContext.tsx');
assert.doesNotMatch(layout, /OperationalContextBar/);
assert.match(context, /localStorage\.removeItem\(LEGACY_STORAGE_KEY\)/);
assert.doesNotMatch(context, /localStorage\.setItem/);
assert.match(context, /useState\(''\)/);
});
test('F6.2 exposes provisional PDF data contracts for Actas and Informes', () => {
const projection = source('../web-v2/src/components/DocumentPdfProjection.tsx');
const actPage = source('../web-v2/src/pages/InspectionActEditorPage.tsx');
const reportPage = source('../web-v2/src/pages/ReportDetailPage.tsx');
assert.match(projection, /PDF PRELIMINAR · ESTRUCTURA PROYECTADA/);
assert.match(projection, /Contrato de datos estable, renderer reemplazable/);
assert.match(projection, /Identificación y contexto/);
assert.match(projection, /Firmas y manifestaciones/);
assert.match(projection, /GEDO e integridad/);
assert.match(projection, /Anexo final/);
assert.match(actPage, /<DocumentPdfProjection kind="act" \/>/);
assert.match(reportPage, /<DocumentPdfProjection kind="report" \/>/);
});
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8');
test('F6.2 protects the frozen Inspection context at database level', () => {
const migration = api('src/database/migrations/1790098200000-f6-2-freeze-inspection-context.ts');
assert.match(migration, /prevent_inspection_context_mutation/);
assert.match(migration, /NEW\.operational_area_id IS DISTINCT FROM OLD\.operational_area_id/);
assert.match(migration, /NEW\.scope_asset_id IS DISTINCT FROM OLD\.scope_asset_id/);
assert.match(migration, /NEW\.operator_company_id IS DISTINCT FROM OLD\.operator_company_id/);
assert.match(migration, /BEFORE UPDATE OF operational_area_id, scope_asset_id, operator_company_id/);
});
test('F6.2 preventive candidates are descendants of the frozen Yacimiento only', () => {
const service = api('src/inspection-visits/inspection-preventive-candidates.service.ts');
const controller = api('src/inspection-visits/inspection-preventive-candidates.controller.ts');
assert.match(service, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/);
assert.match(service, /WITH RECURSIVE scope_tree AS/);
assert.match(service, /child\.parent_id/);
assert.match(service, /asset\.operational_area_id = \$3::uuid/);
assert.match(service, /lower\(asset_type\.code\) IN \('instalacion', 'subinstalacion'\)/);
assert.match(service, /NOT EXISTS \([\s\S]*inspection_visit_assets linked/);
assert.match(controller, /:id\/preventive-candidates/);
});
test('F6.2 WEB uses the server-scoped preventive selector instead of the mutable Area form', () => {
const page = web('src/pages/InspectionVisitEditorF4Page.tsx');
const apiClient = web('src/features/inspections/preventiveCandidatesApi.ts');
assert.match(page, /listInspectionPreventiveCandidates\(id, assetSearch\)/);
assert.doesNotMatch(page, /listAssets\(\{[\s\S]{0,220}operationalAreaId: form\.operationalAreaId/);
assert.match(page, /Sólo se muestran Instalaciones y Subinstalaciones pertenecientes al Yacimiento/);
assert.match(apiClient, /inspection-visits\/\$\{visitId\}\/preventive-candidates/);
});
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string) {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.3 field types start at the frozen Yacimiento instead of the Area', () => {
const structure = source('src/inspection-visits/f3-field-inventory-structure.service.ts');
assert.match(structure, /parentId \?\? base\.parent\.id/);
assert.doesNotMatch(structure, /parentId \?\? base\.context\.area\.id/);
});
test('F6.3 an Acta can start empty and receive Inventory when Hallazgos are added', () => {
const dto = source('src/inspection-acts/dto/create-inspection-act.dto.ts');
const service = source('src/inspection-acts/inspection-acts.service.ts');
const mobile = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt');
assert.doesNotMatch(dto, /ArrayMinSize\(1\)/);
assert.match(service, /if \(assetIds\.length === 0\) return/);
assert.match(mobile, /assetIds = listOfNotNull\(assetId\)/);
});
test('F6.3 every Installation and Subinstallation receives the common field card', () => {
const migration = source('src/database/migrations/1790099100000-f6-3-mobile-field-common-attributes.ts');
for (const label of ['Marca', 'Modelo', 'Capacidad', 'Número de serie', 'Función']) {
assert.match(migration, new RegExp(label));
}
assert.match(migration, /'instalacion','subinstalacion'/);
assert.match(migration, /'TEXT'::asset_attribute_data_type/);
assert.match(migration, /false, true/);
});
test('F6.3 Android follows Inspección → Acta → Hallazgo → Inventario', () => {
const root = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt');
const acts = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt');
const vm = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
assert.match(root, /model\.startVisit\(\); onActs\(\)/);
assert.match(root, /Text\("Nuevo Hallazgo"/);
assert.match(root, /Buscar instalación o subinstalación/);
assert.match(acts, /Text\("\+ Agregar Hallazgo"\)/);
assert.match(acts, /model\.createAct\(newActUrgency\)/);
assert.match(vm, /fun createAct\(urgency: String = "NON_URGENT"\)/);
assert.match(vm, /repository\.fieldInventory\(currentVisit\.id, search, parentId\)/);
});
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DataSource } from 'typeorm';
import { FieldBriefingService } from '../../src/act-administration/field-briefing.service';
test('field briefing convierte el timestamptz de pg a YYYY-MM-DD antes del cast SQL', async () => {
const calls: Array<{ sql: string; params: unknown[] }> = [];
let queryNumber = 0;
const dataSource = {
query: async (sql: string, params: unknown[]) => {
calls.push({ sql, params });
queryNumber += 1;
if (queryNumber === 1) {
return [{
id: '11111111-1111-4111-8111-111111111111',
code: 'INSP-00002-17-09-26',
status: 'DRAFT',
plannedStartAt: new Date('2026-09-17T23:42:00.000Z'),
areaId: '22222222-2222-4222-8222-222222222222',
companyId: '33333333-3333-4333-8333-333333333333',
areaCode: 'PRES-AREA-TEST',
areaName: 'Barrancas',
companyCode: 'PRES-ORG-TEST',
companyName: 'Petróleos Sudamericanos Energy S.A.',
}];
}
return [];
},
} as unknown as DataSource;
const result = await new FieldBriefingService(dataSource).forVisit(
'11111111-1111-4111-8111-111111111111',
);
assert.equal(calls.length, 2);
assert.equal(calls[1]?.params[3], '2026-09-17');
assert.equal(result.plannedOn, '2026-09-17');
assert.deepEqual(result.summary, {
acts: 0,
findings: 0,
inventoryItems: 0,
responseOverdue: 0,
commitmentOverdue: 0,
verificationPending: 0,
});
assert.deepEqual(result.acts, []);
});
@@ -7,6 +7,7 @@ import { ChangeInspectionVisitStatusDto } from '../../src/inspection-visits/dto/
import { CreateInspectionVisitDto } from '../../src/inspection-visits/dto/create-inspection-visit.dto';
import { ReplaceInspectionVisitAssetsDto } from '../../src/inspection-visits/dto/replace-inspection-visit-assets.dto';
import { ReplaceInspectionVisitTeamDto } from '../../src/inspection-visits/dto/replace-inspection-visit-team.dto';
import { UpdateInspectionVisitDto } from '../../src/inspection-visits/dto/update-inspection-visit.dto';
const AREA_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5';
const COMPANY_ID = 'ce96211d-ac15-4421-8868-4185323aff61';
@@ -25,6 +26,25 @@ test('visit DTO accepts only the minimum planning data required for quick creati
assert.equal(dto.leadInspectorUserId, USER_ID);
});
test('visit update DTO keeps Area, Yacimiento and Operadora immutable after creation', async () => {
const contextAttempt = plainToInstance(UpdateInspectionVisitDto, {
operationalAreaId: AREA_ID,
scopeAssetId: AREA_ID,
operatorCompanyId: COMPANY_ID,
});
const errors = await validate(contextAttempt);
assert.equal(errors.some((error) => error.property === 'operationalAreaId'), true);
assert.equal(errors.some((error) => error.property === 'scopeAssetId'), true);
assert.equal(errors.some((error) => error.property === 'operatorCompanyId'), true);
const ordinaryUpdate = plainToInstance(UpdateInspectionVisitDto, {
objective: 'Control preventivo programado',
plannedStartAt: '2026-09-17T12:00:00.000Z',
instructions: 'Coordinar ingreso con la Operadora.',
});
assert.deepEqual(await validate(ordinaryUpdate), []);
});
test('asset and team DTOs reject duplicate references', async () => {
const assets = Object.assign(new ReplaceInspectionVisitAssetsDto(), {
assetIds: [AREA_ID, AREA_ID],
+90 -62
View File
@@ -1,96 +1,124 @@
# Modelo documental de inspecciones
Este documento fija el modelo funcional validado a partir del Informe Técnico
Nº 124/2026, el acta manuscrita adjunta y la planilla de hallazgos entregada.
Este documento fija el modelo funcional vigente para Inspecciones, Actas,
Hallazgos e Informes. La estructura visual definitiva de los documentos todavía
puede cambiar cuando Hidrocarburos entregue las plantillas oficiales; el contrato
de datos, la trazabilidad y las reglas de inmutabilidad deben permanecer estables.
## Canales de operación
| Función | APK móvil | Dashboard web |
|---|---:|---:|
| Planificar visita, activos y equipo | Consulta | Administra |
| Administrar activos y catálogo de hallazgos | Consulta | Administra |
| Iniciar y ejecutar la visita | Inspector | No |
| Crear o modificar el acta y sus hallazgos | Inspector | Sólo consulta |
| Planificar Inspección, alcance y equipo | Consulta | Administra |
| Administrar Inventarios y catálogo de Hallazgos | Consulta | Administra |
| Iniciar y ejecutar la Inspección | Inspector | No |
| Crear Actas y registrar Hallazgos de campo | Inspector | Sólo consulta |
| Incorporar evidencia de campo y GPS | Inspector | Sólo consulta |
| Identificar responsable, firmar y cerrar | Inspector | Sólo consulta |
| Registrar respuesta empresarial, PDF y próximo control | Consulta | Administra |
| Consultar acta, hallazgos e informe sincronizados | Sí | Sí |
| Identificar responsable, firmar y sellar Acta | Inspector | Sólo consulta |
| Preparar y revisar el INF | Consulta | Administra |
| Registrar oficialización GEDO/IF y PDF oficial | Consulta | Administra |
| Registrar respuesta empresarial y seguimientos | Consulta | Administra |
| Consultar Actas, Hallazgos e Informes sincronizados | Sí | Sí |
La API aplica esta separación además de la interfaz: las escrituras operativas
requieren autenticación bearer de la aplicación móvil y el rol `inspector`.
Una sesión web no puede eludir la restricción llamando directamente al endpoint.
La API aplica esta separación además de la interfaz. Las escrituras operativas de
campo se realizan desde la aplicación móvil con las autorizaciones correspondientes;
el dashboard administra planificación, consulta y circuito documental posterior.
## Cardinalidades
## Cardinalidades vigentes
| Origen | Relación | Destino |
|---|---:|---|
| Inspección o visita | 1 a 1 | Acta de inspección |
| Inspección o visita | 1 a muchos | Actas de inspección |
| Acta | 1 a muchos | Hallazgos |
| Hallazgo | muchos a 1 | Activo o equipo del Maestro |
| Hallazgo | muchos a 1 | Ítem del catálogo configurable |
| Hallazgo | 1 a muchos | Evidencias, principalmente fotografías |
| Hallazgo | muchos a 1 | Elemento de Inventario |
| Hallazgo | muchos a 1 | Ítem del catálogo configurable, o propuesta OTROS |
| Hallazgo | 1 a muchos | Evidencias y seguimientos |
| Acta | 1 a muchos | Firmas de inspectores |
| Acta | 1 a 1 | Firma del responsable que atendió la visita |
| Inspección o visita | 1 a 1 | Informe técnico |
| Informe técnico | incorpora | Acta, hallazgos y evidencias congelados |
| Acta | 0 o 1 | Firma/manifestación del responsable de empresa |
| Acta sellada | 0 o 1 | Informe técnico (INF) |
| Informe técnico | 1 a 1 | Acta fuente |
| Informe técnico | incorpora | Acta, Hallazgos, Inventarios y evidencias congelados |
Una Inspección puede, por lo tanto, contener varias Actas y generar varios INF,
pero cada INF conserva una única Acta fuente. No se mezcla el contenido de dos
Actas dentro de un mismo Informe.
## Acta digital
El acta debe reunir como mínimo:
El Acta debe poder reunir como mínimo:
- número anual oficial, fecha, hora y ubicación;
- empresa, área, yacimiento o instalación inspeccionada;
- objeto y resultado de la inspección;
- todos los hallazgos de la visita y sus plazos de corrección;
- número y año oficial del Acta;
- Inspección vinculada, fecha y hora;
- Departamento, Área, Yacimiento, Operadora y ubicación de la actuación;
- objeto, alcance y descripción de lo actuado;
- Instalaciones y Subinstalaciones inspeccionadas;
- todos los Hallazgos del Acta, con descripción, Inventario afectado, base legal,
reincidencia, gravedad y plazos que correspondan;
- inspectores intervinientes;
- identidad, DNI, cargo y correo del responsable de la empresa que recibió al
equipo;
- firmas manuscritas capturadas en pantalla de inspectores y responsable;
- versión inmutable, hash, sello de tiempo y token de verificación.
equipo, cuando corresponda;
- firma de inspectores y manifestación/firma del responsable de empresa, incluida
conformidad, disidencia, negativa o ausencia según el flujo aplicable;
- versión inmutable, hash, sello de tiempo y mecanismo de verificación documental;
- referencias a fotografías/evidencias cuando el formulario oficial determine que
deben formar parte del documento.
Al finalizar la visita, el acta se cierra y ya no admite edición. Se genera el
PDF verificable y se envía al correo del responsable de la empresa y a una
dirección interna de Hidrocarburos definida por configuración. Cada intento y
resultado de entrega debe quedar auditado.
Si se requiere corregir un acta cerrada, se emitirá una rectificativa vinculada
sin reemplazar ni borrar el documento original.
Al sellarse, el Acta queda inmutable. Los cambios posteriores pertenecen al
seguimiento y no reescriben el contenido constatado. El sistema conserva la
instantánea y hashes necesarios para que el PDF se pueda regenerar o sustituir su
renderer sin alterar la evidencia fuente.
## Hallazgos
La planilla entregada contiene 170 modelos de hallazgo agrupados en 19
categorías de equipos, con descripción, normativa y glosario. Se implementará
como catálogo administrable, importable y versionado. No se copiarán esas
opciones como constantes en la interfaz.
El catálogo de Hallazgos es administrable, importable y versionado. Cada Hallazgo
operativo conserva una instantánea de su descripción y base legal para que cambios
posteriores del catálogo no modifiquen documentos ya emitidos.
Cada hallazgo operativo conservará una instantánea de su descripción y base
legal para que posteriores cambios del catálogo no alteren documentos ya
emitidos. También podrá registrar estado, observación específica, activo,
ubicación/GPS, plazo y evidencias.
Cada Hallazgo nace abierto. Se distinguen el plazo exigido, la respuesta y fecha
comprometida por la empresa, la fecha de próximo control y las verificaciones
posteriores. La respuesta empresarial no cierra por sí sola un Hallazgo: el cierre
surge del flujo de verificación autorizado.
Cada hallazgo nace abierto. Se distinguen el plazo exigido, la respuesta y
fecha comprometida por la empresa, y la fecha de próximo control fijada por el
organismo. La respuesta empresarial no cierra el hallazgo: sólo una verificación
posterior autorizada puede hacerlo.
## Informe técnico (INF)
## Informe técnico
El INF nace desde una Acta sellada y tiene numeración propia. Mientras está en
preparación dispone de un Word editable para el desarrollo técnico; modificar ese
Word o los campos narrativos no modifica el Acta ni sus Hallazgos fuente.
El informe se genera después del cierre del acta, con numeración anual global
independiente. Siguiendo el modelo entregado, contempla:
El contrato proyectado del Informe contempla:
- título, número, acta referenciada, empresa, área/yacimiento y autores;
- objetivos;
- antecedentes;
- número/año del INF y Acta referenciada;
- Inspección, Empresa, Departamento, Área, Yacimiento y autores;
- objetivos y antecedentes;
- marco legal;
- resumen ejecutivo;
- descripción de la inspección y de todos los hallazgos;
- registro fotográfico;
- conclusión;
- acta digital completa como anexo final.
- descripción y análisis técnico de la inspección;
- Inventarios inspeccionados;
- detalle de los Hallazgos, reincidencias, gravedad y plazos;
- registro fotográfico/evidencias seleccionadas;
- conclusiones y recomendaciones;
- trazabilidad e integridad de la fuente;
- Acta digital completa como anexo final.
El informe congela las versiones exactas del acta, hallazgos, activos,
evidencias y normativa que incorpora.
Cuando GEDO devuelve el identificador IF y su PDF oficial, ambos se registran de
forma inmutable junto con la fecha de oficialización y el hash del archivo. La
carga del IF/PDF oficial no se interpreta por sí sola como notificación a la
empresa ni inicia plazos que dependan de una notificación posterior.
La solicitud de creación del informe corresponde al flujo del inspector en la
APK después de cerrar la visita. El backend numera, compone y sella el PDF; el
dashboard sólo lo consulta, descarga y administra su seguimiento documental.
## Plantillas PDF provisionales
Hasta recibir los formularios oficiales, la implementación se divide en dos capas:
1. **Contrato de datos estable.** Define qué información debe estar disponible para
Actas e Informes, de dónde proviene, qué versión se congela y qué hashes prueban
su integridad.
2. **Renderer reemplazable.** Encabezado, logos, tipografías, márgenes, cuadros,
orden visual, QR y anexos podrán adaptarse al modelo institucional definitivo
sin modificar el dominio ni volver a diseñar el circuito documental.
El dashboard muestra una sección **PDF preliminar · estructura proyectada** tanto
en la ficha del Acta como en la del Informe. Esa sección no pretende reemplazar el
formulario oficial: sirve para comprobar ahora que el sistema está capturando toda
la información que después deberá volcarse al documento.
La especificación detallada se mantiene en `docs/PDF_PROVISIONAL_STRUCTURE.md`.
+184
View File
@@ -0,0 +1,184 @@
# Estructura provisional de PDFs · PREVIEW-1
Estado: **provisional hasta recepción de formularios oficiales**.
El objetivo de este documento no es fijar todavía la estética institucional. Fija
el conjunto de datos que DH Inspección debe ser capaz de colocar en los PDFs de
Actas e Informes y separa ese contrato del renderer visual.
## Principio de implementación
El dominio documental no debe depender de coordenadas, cuadros o textos impresos
de una plantilla determinada. Los datos se congelan primero en snapshots
versionados; luego un renderer transforma esa fuente a PDF. Cuando llegue el
formulario oficial se reemplaza o adapta el renderer, no la lógica de negocio.
Se consideran **estables**: identidad del documento, vínculos entre entidades,
contenido constatado, Hallazgos, firmas/manifestaciones, fechas, versiones, hashes
y trazabilidad. Se consideran **reemplazables**: logo, membrete, encabezado,
footer, tipografía, colores, tamaños, posiciones, cantidad de columnas, QR y forma
de los anexos.
## PDF de Acta
### 1. Cabecera institucional
Pendiente de modelo oficial. Debe reservar espacio para organismo, dependencia,
logo/membrete, denominación del documento y numeración.
### 2. Identificación
- número y año del Acta;
- código interno;
- código de Inspección/visita;
- fecha y hora de actuación;
- estado documental y versión.
### 3. Contexto territorial y operativo
- Departamento;
- Área;
- Yacimiento;
- Empresa/Operadora vigente;
- ubicación/GPS cuando corresponda;
- Instalación y Subinstalación relacionadas cuando el Acta tenga ese alcance.
### 4. Personas intervinientes
- Inspectores actuantes;
- responsable de la empresa;
- DNI/identificación;
- cargo o función;
- correo/dato de contacto que exija el formulario.
### 5. Objeto y descripción de lo actuado
- objeto o motivo;
- resumen/constatación;
- observaciones;
- Inventarios inspeccionados con código, nombre, tipo y ruta jerárquica.
### 6. Hallazgos
Por cada Hallazgo:
- código;
- Inventario asociado;
- ítem de catálogo u origen OTROS;
- título/denominación;
- descripción constatada;
- base legal congelada;
- reincidencia y referencia al antecedente, si existe;
- gravedad cuando aplique;
- plazo y tipo de días cuando corresponda;
- referencias a evidencia.
### 7. Firmas y manifestaciones
- firma de cada Inspector interviniente;
- firma/manifestación del responsable de empresa;
- conformidad o disidencia;
- texto de disidencia;
- negativa o ausencia con motivo cuando aplique;
- fecha/hora de cada constancia.
### 8. Integridad
- hash del contenido bloqueado;
- hash final del Acta sellada;
- sello de tiempo;
- versión del esquema;
- espacio para token/QR de verificación si el modelo institucional lo utiliza.
### 9. Anexo de evidencias
El sistema debe estar preparado para incorporar fotografías/evidencias, pero su
inclusión dentro del PDF principal queda pendiente de la plantilla oficial.
## Informe técnico · INF
Cada INF tiene **una sola Acta fuente**. Una Inspección puede contener múltiples
Actas y, por lo tanto, múltiples INF independientes.
### 1. Cabecera e identificación
- número/año y código del INF;
- Acta fuente;
- Inspección;
- fecha de generación;
- autores/Inspector responsable;
- Empresa/Operadora;
- Departamento, Área y Yacimiento.
### 2. Objetivos
Texto editable durante la preparación, si la plantilla oficial lo requiere como
campo separado.
### 3. Antecedentes
Antecedentes relevantes para comprender la inspección y el contexto técnico.
### 4. Marco legal
Normativa aplicable y bases legales congeladas con los Hallazgos del Acta fuente.
### 5. Resumen ejecutivo
Campo narrativo editable en estado de preparación.
### 6. Descripción y análisis técnico
Desarrollo técnico editable que no modifica el contenido del Acta ni de sus
Hallazgos.
### 7. Inventarios inspeccionados
Tabla o listado con código, nombre, tipo y ruta territorial/técnica.
### 8. Hallazgos
Detalle de los Hallazgos del Acta fuente, con descripción, base legal,
reincidencia, gravedad, plazos y vínculo con el Inventario.
### 9. Registro fotográfico
Fotografías/evidencias seleccionadas, identificando Hallazgo/Inventario, fecha y
metadatos disponibles. La maquetación se definirá con el modelo oficial.
### 10. Conclusiones y recomendaciones
Bloque técnico editable durante la preparación del INF.
### 11. GEDO
Al oficializar:
- identificador IF;
- fecha/hora de oficialización;
- nombre del PDF oficial;
- SHA-256 del PDF oficial.
La oficialización no equivale automáticamente a notificación a la empresa.
### 12. Integridad y anexo
- hash del snapshot fuente del INF;
- hash del cierre del Acta;
- versión de Acta utilizada;
- Acta fuente completa como anexo final.
## Reemplazo por la plantilla oficial
Cuando se reciba el Acta o Informe oficial en PDF/Word se deberá:
1. inventariar cada campo del formulario;
2. mapearlo contra este contrato;
3. identificar únicamente los datos faltantes;
4. añadir esos datos al dominio sólo si son información real y no mero diseño;
5. construir el renderer definitivo;
6. comparar visualmente una muestra contra el original;
7. conservar el renderer PREVIEW-1 sólo para pruebas históricas si resultara útil.
No se deben alterar Actas ya selladas para acomodar una nueva plantilla. Los
nuevos renderers deben poder representar snapshots históricos de forma trazable.
@@ -0,0 +1,109 @@
import { Icon } from './Icon';
type ProjectionKind = 'act' | 'report';
interface ProjectionSection {
title: string;
description: string;
}
const actSections: ProjectionSection[] = [
{
title: 'Identificación y contexto',
description: 'Número y año de Acta, Inspección vinculada, fecha y hora, Departamento, Área, Yacimiento, Operadora y ubicación de la actuación.',
},
{
title: 'Intervinientes',
description: 'Inspectores actuantes y responsable de la empresa: nombre, DNI, cargo, correo y demás datos de identificación que exija el modelo oficial.',
},
{
title: 'Alcance y constatación',
description: 'Objeto de la actuación, descripción de lo actuado, observaciones e Inventarios inspeccionados con su ruta Instalación / Subinstalación.',
},
{
title: 'Hallazgos',
description: 'Código, Inventario afectado, descripción, base legal, reincidencia, gravedad y plazos aplicables, conservando la instantánea emitida en campo.',
},
{
title: 'Firmas y manifestaciones',
description: 'Firmas de inspectores y responsable de empresa, conformidad o disidencia, manifestación textual, negativa/ausencia si correspondiera y marcas de tiempo.',
},
{
title: 'Integridad y verificación',
description: 'Versión sellada, hash SHA-256, sello de tiempo y espacio reservado para token o QR de verificación documental.',
},
{
title: 'Anexos',
description: 'Registro fotográfico y evidencias sólo si el formulario oficial exige incorporarlos al Acta; el modelo queda preparado para hacerlo sin alterar el dato fuente.',
},
];
const reportSections: ProjectionSection[] = [
{
title: 'Identificación y contexto',
description: 'Número y año del INF, Acta fuente, Inspección, Empresa, Departamento, Área, Yacimiento, fechas y autores/intervinientes.',
},
{
title: 'Objetivos y antecedentes',
description: 'Motivo de la inspección, antecedentes relevantes y alcance técnico del Informe.',
},
{
title: 'Marco legal',
description: 'Normativa y bases legales aplicables, incluyendo las referencias congeladas de los Hallazgos del Acta fuente.',
},
{
title: 'Resumen ejecutivo y desarrollo técnico',
description: 'Resumen ejecutivo, descripción de la inspección, análisis técnico y consideraciones editables durante la preparación del INF.',
},
{
title: 'Inventarios y Hallazgos',
description: 'Inventarios inspeccionados y detalle de Hallazgos con descripción, reincidencia, gravedad, plazos y trazabilidad con el Acta.',
},
{
title: 'Registro fotográfico',
description: 'Fotografías y evidencias seleccionadas, con referencia al Inventario/Hallazgo, fecha y metadatos disponibles.',
},
{
title: 'Conclusiones',
description: 'Conclusión técnica, recomendaciones y texto final sujeto a revisión antes de la oficialización.',
},
{
title: 'GEDO e integridad',
description: 'Identificador IF, fecha de oficialización, hash del PDF oficial y hash de la instantánea congelada que originó el Informe.',
},
{
title: 'Anexo final',
description: 'Acta fuente completa e inmutable como anexo del Informe, manteniendo la relación exacta INF → Acta.',
},
];
export function DocumentPdfProjection({ kind }: { kind: ProjectionKind }) {
const isAct = kind === 'act';
const sections = isAct ? actSections : reportSections;
return <section className="panel document-pdf-projection">
<div className="panel-heading">
<div>
<span className="eyebrow">PDF PRELIMINAR · ESTRUCTURA PROYECTADA</span>
<h2>{isAct ? 'Contenido previsto del Acta' : 'Contenido previsto del Informe'}</h2>
<p className="section-copy">El diseño institucional definitivo todavía no fue recibido. Esta vista fija qué información debe poder alimentar el documento, sin comprometer encabezados, tipografías, logos ni distribución final.</p>
</div>
<span className="status-badge pending">Plantilla oficial pendiente</span>
</div>
<div className="temporal-notice">
<Icon name="clipboard" />
<p><strong>Contrato de datos estable, renderer reemplazable.</strong> Cuando llegue el PDF/Word oficial se adapta la composición visual sobre estos mismos datos, sin rehacer el circuito de Inspecciones, Actas, Hallazgos ni trazabilidad.</p>
</div>
<div className="dossier-link-list">
{sections.map((section, index) => <div key={section.title}>
<div>
<strong>{index + 1}. {section.title}</strong>
<small>{section.description}</small>
</div>
<span>Previsto</span>
</div>)}
</div>
</section>;
}
+7 -20
View File
@@ -10,31 +10,18 @@ interface OperationalContextValue {
clearContext: () => void;
}
const STORAGE_KEY = 'dhv2.operational-context';
const LEGACY_STORAGE_KEY = 'dhv2.operational-context';
const OperationalContext = createContext<OperationalContextValue | null>(null);
function readStoredContext(): { areaId: string; companyId: string } {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return { areaId: '', companyId: '' };
const parsed = JSON.parse(raw) as { areaId?: unknown; companyId?: unknown };
return {
areaId: typeof parsed.areaId === 'string' ? parsed.areaId : '',
companyId: typeof parsed.companyId === 'string' ? parsed.companyId : '',
};
} catch {
return { areaId: '', companyId: '' };
}
}
export function OperationalContextProvider({ children }: PropsWithChildren) {
const initial = useMemo(readStoredContext, []);
const [areaId, setAreaState] = useState(initial.areaId);
const [companyId, setCompanyState] = useState(initial.companyId);
const [areaId, setAreaState] = useState('');
const [companyId, setCompanyState] = useState('');
useEffect(() => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ areaId, companyId }));
}, [areaId, companyId]);
// F6.2 retiró la barra global Área/Operadora. Un contexto persistido sin
// controles visibles podía seguir filtrando pantallas de forma inesperada.
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
}, []);
const value = useMemo<OperationalContextValue>(() => ({
areaId,
@@ -0,0 +1,43 @@
export interface InspectionPreventiveCandidate {
id: string;
code: string;
name: string;
typeName: string;
}
interface PreventiveCandidateResponse {
data: InspectionPreventiveCandidate[];
}
export async function listInspectionPreventiveCandidates(
visitId: string,
search = '',
): Promise<InspectionPreventiveCandidate[]> {
const query = new URLSearchParams();
const term = search.trim();
if (term) query.set('search', term);
const response = await fetch(
`/api/v3/inspection-visits/${visitId}/preventive-candidates${query.size ? `?${query}` : ''}`,
{
method: 'GET',
credentials: 'same-origin',
headers: { Accept: 'application/json' },
},
);
if (!response.ok) {
let message = 'No se pudo cargar el Inventario disponible para esta Inspección';
try {
const payload = await response.json() as { message?: string | string[] };
if (Array.isArray(payload.message)) message = payload.message.join('. ');
else if (payload.message) message = payload.message;
} catch {
// Conserva el mensaje funcional cuando la respuesta no sea JSON.
}
throw new Error(message);
}
const payload = await response.json() as PreventiveCandidateResponse;
return payload.data ?? [];
}
-2
View File
@@ -3,7 +3,6 @@ import { NavLink, Outlet, useNavigate } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { APP_PHASE, APP_VERSION } from '../config/version';
import { Icon } from '../components/Icon';
import { OperationalContextBar } from '../components/OperationalContextBar';
import type { IconName } from '../components/Icon';
interface NavItem {
@@ -118,7 +117,6 @@ export function AppLayout() {
<strong>DH Inspección</strong>
<span className="mobile-version">v{APP_VERSION}</span>
</header>
<OperationalContextBar />
<main className="content"><Outlet /></main>
<footer className="app-footer" aria-label="Versión de la aplicación">
<span>DH Inspección V2</span><span>·</span><strong>v{APP_VERSION}</strong><span>·</span><span>{APP_PHASE}</span>
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { DocumentPdfProjection } from '../components/DocumentPdfProjection';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import {
@@ -70,6 +71,7 @@ export function InspectionActEditorPage() {
<div className="inspection-act-asset-grid">{act.assets.map((asset) => <div className="inspection-member selected" key={asset.id}><span><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></span></div>)}</div>
</section>}
{act && <DocumentPdfProjection kind="act" />}
{act && <InspectionFindingsPanel act={act} />}
{act && <InspectionClosurePanel act={act} />}
@@ -4,6 +4,7 @@ import { Link, useNavigate } from 'react-router';
import { Alert, LoadingBlock } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { SearchableSelect } from '../components/SearchableSelect';
import { apiRequest } from '../lib/api';
interface Option {
id: string;
@@ -23,28 +24,6 @@ interface CreatedInspection {
code: string;
}
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(`/api/v3${url}`, {
credentials: 'same-origin',
...init,
headers: {
Accept: 'application/json',
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...(init?.headers ?? {}),
},
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const message = typeof payload?.message === 'string'
? payload.message
: typeof payload?.error?.message === 'string'
? payload.error.message
: `No se pudo completar la operación (${response.status}).`;
throw new Error(message);
}
return payload as T;
}
function inspectorName(person: Inspector): string {
return `${person.firstName} ${person.lastName}`.trim() || person.username;
}
@@ -76,8 +55,8 @@ export function InspectionVisitCreateF61Page() {
useEffect(() => {
setLoading(true);
Promise.all([
requestJson<{ data: Option[] }>('/inspection-visits/planning-context/departments'),
requestJson<{ data: Inspector[] }>('/inspection-visits/assignees'),
apiRequest<{ data: Option[] }>('/inspection-visits/planning-context/departments'),
apiRequest<{ data: Inspector[] }>('/inspection-visits/assignees'),
]).then(([departmentResponse, inspectorResponse]) => {
setDepartments(departmentResponse.data);
setInspectors(inspectorResponse.data);
@@ -94,7 +73,7 @@ export function InspectionVisitCreateF61Page() {
setYacimientos([]);
setOperators([]);
if (!departmentId) return;
requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/departments/${departmentId}/areas`)
apiRequest<{ data: Option[] }>(`/inspection-visits/planning-context/departments/${departmentId}/areas`)
.then((response) => setAreas(response.data))
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)));
}, [departmentId]);
@@ -105,7 +84,7 @@ export function InspectionVisitCreateF61Page() {
setYacimientos([]);
setOperators([]);
if (!areaId) return;
requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/yacimientos`)
apiRequest<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/yacimientos`)
.then((response) => setYacimientos(response.data))
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)));
}, [areaId]);
@@ -118,7 +97,7 @@ export function InspectionVisitCreateF61Page() {
if (Number.isNaN(parsedStart.getTime())) return;
const at = encodeURIComponent(parsedStart.toISOString());
setLoadingOperators(true);
requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/operators?at=${at}`)
apiRequest<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/operators?at=${at}`)
.then((response) => {
setOperators(response.data);
if (response.data.length === 1 && response.data[0]) setOperatorId(response.data[0].id);
@@ -142,7 +121,7 @@ export function InspectionVisitCreateF61Page() {
setBusy(true);
try {
const created = await requestJson<CreatedInspection>('/inspection-visits', {
const created = await apiRequest<CreatedInspection>('/inspection-visits', {
method: 'POST',
body: JSON.stringify({
operationalAreaId: areaId,
@@ -11,13 +11,18 @@ import {
inspectionStatusClass,
inspectionVisitStatusLabel,
} from '../features/inspections/inspectionPresentation';
import {
listInspectionPreventiveCandidates,
} from '../features/inspections/preventiveCandidatesApi';
import type {
InspectionPreventiveCandidate,
} from '../features/inspections/preventiveCandidatesApi';
import {
createInspectionVisit,
excludeInspectionVisitAsset,
generateInspectionVisitChecklist,
getInspectionVisit,
includeInspectionVisitAsset,
listAssets,
listInspectionAssignees,
listInspectionPlanningAreas,
listInspectionPlanningOperators,
@@ -26,7 +31,6 @@ import {
updateInspectionVisit,
} from '../lib/api';
import type {
AssetListItem,
InspectionPerson,
InspectionPlanningContextAsset,
InspectionVisit,
@@ -100,7 +104,7 @@ export function InspectionVisitEditorF4Page() {
const [areas, setAreas] = useState<InspectionPlanningContextAsset[]>([]);
const [operators, setOperators] = useState<InspectionPlanningContextAsset[]>([]);
const [assignees, setAssignees] = useState<InspectionPerson[]>([]);
const [assets, setAssets] = useState<AssetListItem[]>([]);
const [assets, setAssets] = useState<InspectionPreventiveCandidate[]>([]);
const [assetSearch, setAssetSearch] = useState('');
const [newAssetId, setNewAssetId] = useState('');
const [leadInspectorId, setLeadInspectorId] = useState('');
@@ -136,9 +140,9 @@ export function InspectionVisitEditorF4Page() {
};
useEffect(() => {
listInspectionPlanningAreas().then(setAreas).catch(() => undefined);
if (isNew) listInspectionPlanningAreas().then(setAreas).catch(() => undefined);
if (canAssign) listInspectionAssignees().then(setAssignees).catch(() => undefined);
}, [canAssign]);
}, [canAssign, isNew]);
useEffect(() => {
if (!isNew || form.operationalAreaId || !context.areaId) return;
@@ -158,29 +162,27 @@ export function InspectionVisitEditorF4Page() {
}, [id]);
useEffect(() => {
if (!form.operationalAreaId) {
if (!isNew || !form.operationalAreaId) {
setOperators([]);
return;
}
listInspectionPlanningOperators(form.operationalAreaId)
.then(setOperators)
.catch(() => setOperators([]));
}, [form.operationalAreaId]);
}, [form.operationalAreaId, isNew]);
useEffect(() => {
if (!form.operationalAreaId || !form.operatorCompanyId) {
if (!id || !visit) {
setAssets([]);
return;
}
const timer = window.setTimeout(() => {
listAssets({
pageSize: 100,
search: assetSearch.trim(),
operationalAreaId: form.operationalAreaId,
}).then((response) => setAssets(response.data)).catch(() => undefined);
listInspectionPreventiveCandidates(id, assetSearch)
.then(setAssets)
.catch((requestError) => setError(errorMessage(requestError)));
}, 220);
return () => window.clearTimeout(timer);
}, [assetSearch, form.operationalAreaId, form.operatorCompanyId]);
}, [id, assetSearch, visit?.updatedAt]);
const planningEditable = !visit || visit.status === 'DRAFT' || visit.status === 'PLANNED';
const activeAssetIds = useMemo(() => new Set(visit?.assets.map((asset) => asset.id) ?? []), [visit]);
@@ -209,13 +211,10 @@ export function InspectionVisitEditorF4Page() {
} else if (id) {
applyVisit(await updateInspectionVisit(id, {
objective: form.objective.trim() || null,
scopeAssetId: visit?.scopeAsset?.id ?? null,
operationalAreaId: form.operationalAreaId || null,
operatorCompanyId: form.operatorCompanyId || null,
plannedStartAt,
instructions: form.instructions.trim() || null,
}));
setSuccess('Planificación actualizada. Regenerá el checklist si cambió contexto o fecha.');
setSuccess('Planificación actualizada. Si cambió la fecha, regenerá el checklist.');
}
} catch (requestError) {
setError(errorMessage(requestError));
@@ -240,6 +239,7 @@ export function InspectionVisitEditorF4Page() {
setBusy(true); setError(''); setSuccess('');
try {
applyVisit(await replaceInspectionVisitAssets(id, [...activeAssetIds, newAssetId]), false);
setAssets((current) => current.filter((asset) => asset.id !== newAssetId));
setNewAssetId('');
setSuccess('Registro agregado como preventivo.');
} catch (requestError) { setError(errorMessage(requestError)); }
@@ -340,12 +340,12 @@ export function InspectionVisitEditorF4Page() {
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<form className={`panel form-panel ${isNew ? 'inspection-quick-create' : ''}`} onSubmit={saveGeneral}>
<div className="panel-heading"><div><span className="eyebrow">{isNew ? 'CREACIÓN RÁPIDA' : 'PLANIFICACIÓN'}</span><h2>Contexto y fecha de inicio</h2><p className="section-copy">No existe título independiente ni fecha de fin planificada. El cierre real se registra al terminar el trabajo de campo.</p></div>{visit && <small className="muted">Actualizado {formatDate(visit.updatedAt)}</small>}</div>
<div className="panel-heading"><div><span className="eyebrow">{isNew ? 'CREACIÓN RÁPIDA' : 'PLANIFICACIÓN'}</span><h2>Contexto y fecha de inicio</h2><p className="section-copy">El Área, Yacimiento y Operadora quedan fijados al crear la Inspección. La fecha puede reprogramarse antes del trabajo de campo.</p></div>{visit && <small className="muted">Actualizado {formatDate(visit.updatedAt)}</small>}</div>
{visit && <div className="inspection-generated-code"><small>Identificador institucional</small><strong>{visit.code}</strong></div>}
<div className="form-grid">
<label className="field"><span>Área</span><SearchableSelect searchPlaceholder="Buscar Área…" value={form.operationalAreaId} onChange={(event) => { context.setAreaId(event.target.value); setForm((current) => ({ ...current, operationalAreaId: event.target.value, operatorCompanyId: '' })); }} required disabled={!canManage || !planningEditable}><option value="">Seleccionar Área</option>{areas.map((area) => <option key={area.id} value={area.id}>{area.name} · {area.code}</option>)}</SearchableSelect></label>
{!isNew && <label className="field"><span>Yacimiento</span><input value={visit?.scopeAsset?.name ?? 'Sin Yacimiento'} readOnly aria-readonly="true" /></label>}
<label className="field"><span>Operadora</span><SearchableSelect searchPlaceholder="Buscar Operadora…" value={form.operatorCompanyId} onChange={(event) => { context.setCompanyId(event.target.value); setForm((current) => ({ ...current, operatorCompanyId: event.target.value })); }} required disabled={!canManage || !planningEditable || !form.operationalAreaId}><option value="">Seleccionar Operadora</option>{operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name} · {operator.code}</option>)}</SearchableSelect></label>
{isNew ? <label className="field"><span>Área</span><SearchableSelect searchPlaceholder="Buscar Área…" value={form.operationalAreaId} onChange={(event) => { context.setAreaId(event.target.value); setForm((current) => ({ ...current, operationalAreaId: event.target.value, operatorCompanyId: '' })); }} required disabled={!canManage || !planningEditable}><option value="">Seleccionar Área</option>{areas.map((area) => <option key={area.id} value={area.id}>{area.name} · {area.code}</option>)}</SearchableSelect></label> : <label className="field"><span>Área</span><input value={visit?.operationalArea ? `${visit.operationalArea.name} · ${visit.operationalArea.code}` : 'Sin Área'} readOnly aria-readonly="true" /></label>}
{!isNew && <label className="field"><span>Yacimiento</span><input value={visit?.scopeAsset ? `${visit.scopeAsset.name} · ${visit.scopeAsset.code}` : 'Sin Yacimiento'} readOnly aria-readonly="true" /></label>}
{isNew ? <label className="field"><span>Operadora</span><SearchableSelect searchPlaceholder="Buscar Operadora…" value={form.operatorCompanyId} onChange={(event) => { context.setCompanyId(event.target.value); setForm((current) => ({ ...current, operatorCompanyId: event.target.value })); }} required disabled={!canManage || !planningEditable || !form.operationalAreaId}><option value="">Seleccionar Operadora</option>{operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name} · {operator.code}</option>)}</SearchableSelect></label> : <label className="field"><span>Operadora</span><input value={visit?.operatorCompany ? `${visit.operatorCompany.name} · ${visit.operatorCompany.code}` : 'Sin Operadora'} readOnly aria-readonly="true" /></label>}
<label className="field"><span>Fecha y hora de inicio</span><input type="datetime-local" value={form.plannedStartAt} onChange={(event) => setForm((current) => ({ ...current, plannedStartAt: event.target.value }))} required disabled={!canManage || !planningEditable} /></label>
{isNew && <label className="field"><span>Inspector responsable</span><SearchableSelect searchPlaceholder="Buscar Inspector…" value={leadInspectorId} onChange={(event) => setLeadInspectorId(event.target.value)} required disabled={!canAssign}><option value="">Seleccionar Inspector</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)} · {person.username}</option>)}</SearchableSelect></label>}
</div>
@@ -354,15 +354,15 @@ export function InspectionVisitEditorF4Page() {
</form>
{visit && <article className="panel inspection-checklist-panel">
<div className="panel-heading"><div><span className="eyebrow">CHECKLIST TÉCNICO</span><h2>Antecedentes y controles</h2><p className="section-copy">Se construye por Área, Operadora y fecha. Las respuestas administrativas de empresa no modifican este checklist.</p></div>{canManage && planningEditable && <button type="button" className="button secondary" onClick={() => void regenerateChecklist()} disabled={busy}>Regenerar checklist</button>}</div>
{visit.checklist.stale && <Alert>El contexto o la fecha cambió. Regenerá el checklist antes de planificar.</Alert>}
<div className="panel-heading"><div><span className="eyebrow">CHECKLIST TÉCNICO</span><h2>Antecedentes y controles</h2><p className="section-copy">Se construye para el Área, Yacimiento y Operadora fijados en la Inspección, tomando la fecha planificada. Las respuestas administrativas de empresa no modifican este checklist.</p></div>{canManage && planningEditable && <button type="button" className="button secondary" onClick={() => void regenerateChecklist()} disabled={busy}>Regenerar checklist</button>}</div>
{visit.checklist.stale && <Alert>La fecha cambió. Regenerá el checklist antes de planificar.</Alert>}
<div className="inspection-checklist-metrics"><div><strong>{visit.checklist.verificationOverdue}</strong><span>controles vencidos</span></div><div><strong>{visit.checklist.upcomingControls}</strong><span>próximos 30 días</span></div><div><strong>{visit.checklist.antecedents}</strong><span>antecedentes</span></div><div><strong>{visit.checklist.actionableAssets}</strong><span>registros sugeridos</span></div></div>
{visit.checklist.items.length === 0 ? <EmptyState title="Sin antecedentes" text="No hay Hallazgos históricos técnicos para este contexto." /> : <div className="table-scroll"><table><thead><tr><th>Tipo</th><th>Hallazgo</th><th>Inventario</th><th>Fecha</th><th>Gravedad</th></tr></thead><tbody>{visit.checklist.items.map((item) => <tr key={item.id}><td><span className={`status-badge ${item.itemKind === 'ANTECEDENT' ? '' : 'warning'}`}>{checklistLabel(item.itemKind)}</span></td><td><Link className="history-asset-link" to={`/hallazgos/${item.findingId}`}><strong>{item.findingTitle}</strong><small>{item.findingCode}</small></Link></td><td><Link className="history-asset-link" to={`/inventarios/${item.asset.id}`}><strong>{item.asset.name}</strong><small>{item.asset.code} · {item.asset.typeName}</small></Link></td><td>{formatDateOnly(item.referenceOn)}</td><td>{item.severity ?? '—'}</td></tr>)}</tbody></table></div>}
</article>}
{visit && visit.verificationFindings.length > 0 && <article className="panel verification-visit-findings"><div className="panel-heading"><div><span className="eyebrow">VERIFICACIÓN</span><h2>Hallazgos a controlar</h2></div><span className="count-pill">{visit.verificationFindings.length}</span></div><div className="dossier-link-list">{visit.verificationFindings.map((finding) => <Link key={finding.id} to={`/hallazgos/${finding.id}`}><div><strong>{finding.title}</strong><small>{finding.code} · {finding.assetName} · objetivo {formatDateOnly(finding.targetControlOn ?? finding.nextControlOn)}</small>{finding.resultNotes && <small>{finding.resultNotes}</small>}</div><span>{finding.outcome === 'RESOLVED' ? 'Solucionado' : finding.outcome === 'NOT_RESOLVED' ? 'No solucionado' : finding.outcome === 'REQUIRES_NEW_DATE' ? 'Reprogramar' : 'Pendiente'}</span><Icon name="chevron" size={16} /></Link>)}</div></article>}
{visit && planningEditable && canManage && <form className="panel survey-add-target" onSubmit={addPreventiveAsset}><div className="panel-heading"><div><span className="eyebrow">PREVENTIVO</span><h2>Agregar Inventario sin pendiente previo</h2></div></div><label className="field"><span>Buscar Inventario</span><input value={assetSearch} onChange={(event) => setAssetSearch(event.target.value)} placeholder="Código o nombre" /></label><label className="field"><span>Inventario</span><SearchableSelect value={newAssetId} onChange={(event) => setNewAssetId(event.target.value)} required><option value="">Seleccionar</option>{candidateAssets.map((asset) => <option key={asset.id} value={asset.id}>{asset.code} · {asset.name} · {asset.type.name}</option>)}</SearchableSelect></label><div className="form-actions"><button className="button primary" disabled={busy || !newAssetId}><Icon name="plus" />Agregar preventivo</button></div></form>}
{visit && planningEditable && canManage && <form className="panel survey-add-target" onSubmit={addPreventiveAsset}><div className="panel-heading"><div><span className="eyebrow">PREVENTIVO</span><h2>Agregar Inventario sin pendiente previo</h2><p className="section-copy">Sólo se muestran Instalaciones y Subinstalaciones pertenecientes al Yacimiento fijado para esta Inspección.</p></div></div><label className="field"><span>Buscar Inventario</span><input value={assetSearch} onChange={(event) => setAssetSearch(event.target.value)} placeholder="Código o nombre" /></label><label className="field"><span>Inventario</span><SearchableSelect value={newAssetId} onChange={(event) => setNewAssetId(event.target.value)} required><option value="">Seleccionar</option>{candidateAssets.map((asset) => <option key={asset.id} value={asset.id}>{asset.code} · {asset.name} · {asset.typeName}</option>)}</SearchableSelect></label><div className="form-actions"><button className="button primary" disabled={busy || !newAssetId}><Icon name="plus" />Agregar preventivo</button></div></form>}
{visit && <article className="table-panel inspection-assets"><div className="table-summary"><strong>{visit.assets.length} incluidos</strong><span>{visit.checklist.excludedAssets} excluidos con trazabilidad</span></div>{visit.planningAssets.length === 0 ? <EmptyState title="Sin Inventarios" text="Generá el checklist o agregá un preventivo." /> : <div className="table-scroll"><table><thead><tr><th>Inventario</th><th>Origen</th><th>Estado</th><th>Motivo</th><th /></tr></thead><tbody>{visit.planningAssets.map((asset) => <tr key={asset.id}><td><Link className="history-asset-link" to={`/inventarios/${asset.id}`}><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></Link></td><td>{sourceLabel(asset.planningSource)}</td><td>{asset.included ? 'Incluido' : 'Excluido'}</td><td>{asset.exclusionReason ?? '—'}</td><td>{canManage && planningEditable && (asset.included ? <button type="button" className="button danger-outline compact" onClick={() => void excludeAsset(asset.id)}>Excluir</button> : <button type="button" className="button secondary compact" onClick={() => void reincludeAsset(asset.id)}>Reincorporar</button>)}</td></tr>)}</tbody></table></div>}</article>}
+4 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { DocumentPdfProjection } from '../components/DocumentPdfProjection';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { SearchableSelect } from '../components/SearchableSelect';
@@ -193,7 +194,7 @@ export function ReportDetailPage() {
{success && <Alert type="success">{success}</Alert>}
{report && <>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un INF corresponde a una sola Acta.</strong> El Word puede editarse durante la preparación del Informe. El Acta sellada y los Hallazgos que contiene permanecen inmutables.</p></div>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un INF corresponde a una sola Acta.</strong> Una Inspección puede contener varias Actas y, por lo tanto, varios INF independientes. El Word puede editarse durante la preparación; el Acta sellada y sus Hallazgos permanecen inmutables.</p></div>
<section className="panel report-summary-panel">
<div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD DOCUMENTAL</span><h2>{report.code}</h2></div><small className="muted">Versión del Acta: {report.actVersion}</small></div>
@@ -210,6 +211,8 @@ export function ReportDetailPage() {
</div>
</section>
<DocumentPdfProjection kind="report" />
<section className="panel">
<div className="panel-heading"><div><span className="eyebrow">WORD EDITABLE</span><h2>Preparación del INF</h2><p className="section-copy">El Inspector puede revisar y ajustar el texto del Informe antes de incorporarlo a GEDO. Esta edición no altera el Acta fuente.</p></div>{report.wordStatus === 'READY' && <a className="button secondary" href={inspectionReportWordDownloadUrl(report.id)}>Descargar Word</a>}</div>
<form className="form-section" onSubmit={saveNarrative}>