Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a414d0ed36 | ||
|
|
0b731b722f | ||
|
|
678b0f1792 | ||
|
|
c71afab9af | ||
|
|
29208f4e1f | ||
|
|
4523aef932 | ||
|
|
011b3e7fcb | ||
|
|
9b7d67bea2 | ||
|
|
f0b92c00e1 | ||
|
|
cab9cd7c01 |
@@ -1,5 +1,5 @@
|
||||
name: Android CI / RC
|
||||
# F6.8 offline/document barrier: lint + real tests + debug artifact + release compile.
|
||||
# F6.14 yacimiento-scope barrier: lint + real tests + debug artifact + release compile.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
|
||||
sha256sum "$apk" > "${apk}.sha256"
|
||||
{
|
||||
echo "phase=F6.8"
|
||||
echo "phase=F6.14"
|
||||
echo "version=$version"
|
||||
echo "versionCode=$code"
|
||||
echo "commit=$GITHUB_SHA"
|
||||
|
||||
+10
-3
@@ -1,9 +1,9 @@
|
||||
# DH Inspección Android · release de campo 0.19.11
|
||||
# DH Inspección Android · release de campo 0.19.13
|
||||
|
||||
## Candidata vigente
|
||||
|
||||
- `versionName`: **0.19.11**; `versionCode`: **39**.
|
||||
- API: `https://dhv2.korexlabs.com/api/v3/`; compatible con API 0.29.0-9 / WEB 0.23.0-6.
|
||||
- `versionName`: **0.19.13**; `versionCode`: **41**.
|
||||
- API: `https://dhv2.korexlabs.com/api/v3/`; compatible con API 0.29.0-14 / WEB 0.23.0-10.
|
||||
- Antes de cerrar el contenido, el inspector escribe una descripción real de lo actuado.
|
||||
- La descripción se sincroniza antes del bloqueo, también cuando se trabajó sin conexión.
|
||||
- Un acta anterior, ya sellada, conserva el texto originalmente registrado.
|
||||
@@ -161,3 +161,10 @@ Esta candidata requiere conexión. No implementa trabajo offline ni cola persist
|
||||
- El borrador de Acta nace vacío, sin Inventario y sin urgencia predeterminada.
|
||||
- La urgencia se exige al cerrar el Acta y se persiste en la misma operación que la vuelve inmutable.
|
||||
- `inspection_acts.urgency` puede quedar pendiente durante el borrador; el bloqueo exige la decisión y calcula el vencimiento desde allí.
|
||||
|
||||
## F6.14 · alcance territorial móvil
|
||||
|
||||
- Una nueva Inspección abierta desde la APK exige seleccionar Departamento → Área → Yacimiento → Operadora.
|
||||
- El Yacimiento elegido queda congelado como alcance de la Inspección y del Acta.
|
||||
- Los Hallazgos pueden registrarse sobre ese Yacimiento, sus Instalaciones o sus Subinstalaciones; nunca fuera de esa rama.
|
||||
- Al abrir una Inspección que ya está en curso, la APK entra directamente a Actas y Hallazgos.
|
||||
|
||||
@@ -14,8 +14,8 @@ android {
|
||||
applicationId = "com.korexlabs.dhinspeccion"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 39
|
||||
versionName = "0.19.11"
|
||||
versionCode = 41
|
||||
versionName = "0.19.13"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
+37
-11
@@ -25,22 +25,36 @@ data class MobilePlanningAssetResponse(
|
||||
)
|
||||
|
||||
data class OpenMobileInspectionRequest(
|
||||
val departmentId: String,
|
||||
val operationalAreaId: String,
|
||||
val scopeAssetId: String,
|
||||
val operatorCompanyId: String,
|
||||
)
|
||||
|
||||
private interface MobileInspectionOpenApi {
|
||||
@GET("inspection-visits/mobile/planning-context/areas")
|
||||
suspend fun areas(
|
||||
@GET("inspection-visits/mobile/planning-context/departments")
|
||||
suspend fun departments(
|
||||
@Header("Authorization") authorization: String,
|
||||
): MobilePlanningAssetResponse
|
||||
|
||||
@GET("inspection-visits/mobile/planning-context/areas/{areaId}/operators")
|
||||
suspend fun operators(
|
||||
@GET("inspection-visits/mobile/planning-context/departments/{departmentId}/areas")
|
||||
suspend fun areas(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("departmentId") departmentId: String,
|
||||
): MobilePlanningAssetResponse
|
||||
|
||||
@GET("inspection-visits/mobile/planning-context/areas/{areaId}/yacimientos")
|
||||
suspend fun yacimientos(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("areaId") areaId: String,
|
||||
): MobilePlanningAssetResponse
|
||||
|
||||
@GET("inspection-visits/mobile/planning-context/yacimientos/{yacimientoId}/operators")
|
||||
suspend fun operators(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("yacimientoId") yacimientoId: String,
|
||||
): MobilePlanningAssetResponse
|
||||
|
||||
@POST("inspection-visits/mobile/open")
|
||||
suspend fun open(
|
||||
@Header("Authorization") authorization: String,
|
||||
@@ -61,18 +75,31 @@ class MobileInspectionOpenRepository(context: Context) {
|
||||
.build()
|
||||
.create(MobileInspectionOpenApi::class.java)
|
||||
|
||||
suspend fun areas(): MobilePlanningAssetResponse = authorized { session ->
|
||||
api.areas("Bearer ${session.accessToken}")
|
||||
suspend fun departments(): MobilePlanningAssetResponse = authorized { session ->
|
||||
api.departments("Bearer ${session.accessToken}")
|
||||
}
|
||||
|
||||
suspend fun operators(areaId: String): MobilePlanningAssetResponse = authorized { session ->
|
||||
api.operators("Bearer ${session.accessToken}", areaId)
|
||||
suspend fun areas(departmentId: String): MobilePlanningAssetResponse = authorized { session ->
|
||||
api.areas("Bearer ${session.accessToken}", departmentId)
|
||||
}
|
||||
|
||||
suspend fun open(areaId: String, companyId: String): VisitDetail = authorized { session ->
|
||||
suspend fun yacimientos(areaId: String): MobilePlanningAssetResponse = authorized { session ->
|
||||
api.yacimientos("Bearer ${session.accessToken}", areaId)
|
||||
}
|
||||
|
||||
suspend fun operators(yacimientoId: String): MobilePlanningAssetResponse = authorized { session ->
|
||||
api.operators("Bearer ${session.accessToken}", yacimientoId)
|
||||
}
|
||||
|
||||
suspend fun open(
|
||||
departmentId: String,
|
||||
areaId: String,
|
||||
yacimientoId: String,
|
||||
companyId: String,
|
||||
): VisitDetail = authorized { session ->
|
||||
api.open(
|
||||
"Bearer ${session.accessToken}",
|
||||
OpenMobileInspectionRequest(areaId, companyId),
|
||||
OpenMobileInspectionRequest(departmentId, areaId, yacimientoId, companyId),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,5 +116,4 @@ class MobileInspectionOpenRepository(context: Context) {
|
||||
|
||||
private suspend fun refresh(previous: StoredSession): StoredSession =
|
||||
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
|
||||
|
||||
}
|
||||
|
||||
@@ -47,19 +47,41 @@ fun MobileHomeScreen(model: MainViewModel) {
|
||||
val openRepository = remember(context) { MobileInspectionOpenRepository(context) }
|
||||
|
||||
var showOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var departments by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
|
||||
var areas by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
|
||||
var yacimientos by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
|
||||
var operators by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
|
||||
var selectedDepartmentId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var selectedAreaId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var selectedYacimientoId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var selectedCompanyId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var loadingContext by remember { mutableStateOf(false) }
|
||||
var opening by remember { mutableStateOf(false) }
|
||||
var localError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun loadAreas() {
|
||||
fun loadDepartments() {
|
||||
scope.launch {
|
||||
loadingContext = true
|
||||
localError = null
|
||||
runCatching { openRepository.areas().data }
|
||||
runCatching { openRepository.departments().data }
|
||||
.onSuccess { departments = it }
|
||||
.onFailure { localError = DhRepository.humanError(it) }
|
||||
loadingContext = false
|
||||
}
|
||||
}
|
||||
|
||||
fun chooseDepartment(departmentId: String) {
|
||||
selectedDepartmentId = departmentId
|
||||
selectedAreaId = null
|
||||
selectedYacimientoId = null
|
||||
selectedCompanyId = null
|
||||
areas = emptyList()
|
||||
yacimientos = emptyList()
|
||||
operators = emptyList()
|
||||
scope.launch {
|
||||
loadingContext = true
|
||||
localError = null
|
||||
runCatching { openRepository.areas(departmentId).data }
|
||||
.onSuccess { areas = it }
|
||||
.onFailure { localError = DhRepository.humanError(it) }
|
||||
loadingContext = false
|
||||
@@ -68,20 +90,39 @@ fun MobileHomeScreen(model: MainViewModel) {
|
||||
|
||||
fun chooseArea(areaId: String) {
|
||||
selectedAreaId = areaId
|
||||
selectedYacimientoId = null
|
||||
selectedCompanyId = null
|
||||
yacimientos = emptyList()
|
||||
operators = emptyList()
|
||||
scope.launch {
|
||||
loadingContext = true
|
||||
localError = null
|
||||
runCatching { openRepository.yacimientos(areaId).data }
|
||||
.onSuccess { yacimientos = it }
|
||||
.onFailure { localError = DhRepository.humanError(it) }
|
||||
loadingContext = false
|
||||
}
|
||||
}
|
||||
|
||||
fun chooseYacimiento(yacimientoId: String) {
|
||||
selectedYacimientoId = yacimientoId
|
||||
selectedCompanyId = null
|
||||
operators = emptyList()
|
||||
scope.launch {
|
||||
loadingContext = true
|
||||
localError = null
|
||||
runCatching { openRepository.operators(areaId).data }
|
||||
.onSuccess { operators = it }
|
||||
runCatching { openRepository.operators(yacimientoId).data }
|
||||
.onSuccess {
|
||||
operators = it
|
||||
if (it.size == 1) selectedCompanyId = it.first().id
|
||||
}
|
||||
.onFailure { localError = DhRepository.humanError(it) }
|
||||
loadingContext = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(showOpen) {
|
||||
if (showOpen && areas.isEmpty()) loadAreas()
|
||||
if (showOpen && departments.isEmpty()) loadDepartments()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
|
||||
@@ -142,27 +183,57 @@ fun MobileHomeScreen(model: MainViewModel) {
|
||||
) {
|
||||
Text("Abrir inspección en campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Elegí el Área y la Operadora vigente. La inspección se crea autoasignada a vos y queda iniciada con la fecha y hora del servidor.",
|
||||
"Definí el contexto completo antes de abrir el Acta: Departamento → Área → Yacimiento → Operadora. La inspección queda autoasignada a vos y se inicia con la hora del servidor.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
Text("1. Área", fontWeight = FontWeight.SemiBold)
|
||||
if (loadingContext && areas.isEmpty()) CircularProgressIndicator()
|
||||
Text("1. Departamento", fontWeight = FontWeight.SemiBold)
|
||||
if (loadingContext && departments.isEmpty()) CircularProgressIndicator()
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(areas, key = { it.id }) { area ->
|
||||
items(departments, key = { it.id }) { department ->
|
||||
AssistChip(
|
||||
onClick = { chooseArea(area.id) },
|
||||
label = { Text(if (area.id == selectedAreaId) "✓ ${area.name}" else area.name) },
|
||||
onClick = { chooseDepartment(department.id) },
|
||||
label = { Text(if (department.id == selectedDepartmentId) "✓ ${department.name}" else department.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedDepartmentId != null) {
|
||||
Text("2. Área", fontWeight = FontWeight.SemiBold)
|
||||
if (loadingContext && areas.isEmpty()) CircularProgressIndicator()
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(areas, key = { it.id }) { area ->
|
||||
AssistChip(
|
||||
onClick = { chooseArea(area.id) },
|
||||
label = { Text(if (area.id == selectedAreaId) "✓ ${area.name}" else area.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedAreaId != null) {
|
||||
Text("2. Operadora", fontWeight = FontWeight.SemiBold)
|
||||
Text("3. Yacimiento", fontWeight = FontWeight.SemiBold)
|
||||
if (loadingContext && yacimientos.isEmpty()) {
|
||||
CircularProgressIndicator()
|
||||
} else if (yacimientos.isEmpty()) {
|
||||
Text("No hay Yacimientos disponibles para el Área seleccionada.", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(yacimientos, key = { it.id }) { yacimiento ->
|
||||
AssistChip(
|
||||
onClick = { chooseYacimiento(yacimiento.id) },
|
||||
label = { Text(if (yacimiento.id == selectedYacimientoId) "✓ ${yacimiento.name}" else yacimiento.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedYacimientoId != null) {
|
||||
Text("4. Operadora", fontWeight = FontWeight.SemiBold)
|
||||
if (loadingContext && operators.isEmpty()) {
|
||||
CircularProgressIndicator()
|
||||
} else if (operators.isEmpty()) {
|
||||
Text("No hay una Operadora vigente para el Área seleccionada.", color = MaterialTheme.colorScheme.error)
|
||||
Text("El Yacimiento no tiene una Operadora válida configurada.", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(operators, key = { it.id }) { company ->
|
||||
@@ -176,12 +247,14 @@ fun MobileHomeScreen(model: MainViewModel) {
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val departmentId = selectedDepartmentId ?: return@Button
|
||||
val areaId = selectedAreaId ?: return@Button
|
||||
val yacimientoId = selectedYacimientoId ?: return@Button
|
||||
val companyId = selectedCompanyId ?: return@Button
|
||||
scope.launch {
|
||||
opening = true
|
||||
localError = null
|
||||
runCatching { openRepository.open(areaId, companyId) }
|
||||
runCatching { openRepository.open(departmentId, areaId, yacimientoId, companyId) }
|
||||
.onSuccess { opened ->
|
||||
showOpen = false
|
||||
model.openVisit(opened.id)
|
||||
@@ -191,9 +264,9 @@ fun MobileHomeScreen(model: MainViewModel) {
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = selectedAreaId != null && selectedCompanyId != null && !opening && !loadingContext,
|
||||
enabled = selectedDepartmentId != null && selectedAreaId != null && selectedYacimientoId != null && selectedCompanyId != null && !opening && !loadingContext,
|
||||
) {
|
||||
Text(if (opening) "Abriendo…" else "Abrir inspección ahora")
|
||||
Text(if (opening) "Abriendo…" else "Abrir inspección y continuar al Acta")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,6 +301,7 @@ private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
|
||||
Text(visit.code, fontWeight = FontWeight.Bold)
|
||||
Text(visitStatusLabelEs(visit.status))
|
||||
}
|
||||
Text(visit.scopeAsset?.name ?: "Yacimiento sin definir", fontWeight = FontWeight.SemiBold)
|
||||
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
|
||||
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
|
||||
visit.plannedStartAt?.let {
|
||||
|
||||
+1
-1
@@ -261,7 +261,7 @@ fun ModernMobileActsScreen(
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Elegí una Instalación o Subinstalación existente, o creala en campo si todavía no está registrada.",
|
||||
"Elegí el Yacimiento, una Instalación o una Subinstalación. El Hallazgo siempre queda dentro del Yacimiento de esta Acta.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
|
||||
|
||||
@@ -107,7 +107,9 @@ private data class ModernGeoSnapshot(
|
||||
@Composable
|
||||
fun ModernVisitRoot(model: MainViewModel) {
|
||||
val visit = model.visit ?: return
|
||||
var screenName by rememberSaveable(visit.id) { mutableStateOf(ModernVisitScreen.OVERVIEW.name) }
|
||||
var screenName by rememberSaveable(visit.id) {
|
||||
mutableStateOf(if (visit.status == "IN_PROGRESS") ModernVisitScreen.ACTS.name else ModernVisitScreen.OVERVIEW.name)
|
||||
}
|
||||
val screen = runCatching { ModernVisitScreen.valueOf(screenName) }.getOrDefault(ModernVisitScreen.OVERVIEW)
|
||||
|
||||
when (screen) {
|
||||
@@ -938,7 +940,7 @@ private fun ModernInventoryBrowse(
|
||||
OutlinedTextField(
|
||||
value = search,
|
||||
onValueChange = onSearchChange,
|
||||
label = { Text("Buscar Instalación o Subinstalación") },
|
||||
label = { Text("Buscar Yacimiento, Instalación o Subinstalación") },
|
||||
placeholder = { Text("Nombre, código o dato técnico") },
|
||||
leadingIcon = { Icon(Icons.Filled.Search, null) },
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
|
||||
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
|
||||
@Test
|
||||
fun debugBuildKeepsSeparateApplicationIdentity() {
|
||||
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
|
||||
assertEquals(39, BuildConfig.VERSION_CODE)
|
||||
assertEquals("0.19.11-debug", BuildConfig.VERSION_NAME)
|
||||
assertEquals(41, BuildConfig.VERSION_CODE)
|
||||
assertEquals("0.19.13-debug", BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-12",
|
||||
"version": "0.29.0-15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-12",
|
||||
"version": "0.29.0-15",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-12",
|
||||
"version": "0.29.0-15",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
|
||||
@@ -63,3 +63,25 @@ export class MapAssetsController {
|
||||
return this.geometries.map(query);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('map/context')
|
||||
export class MapOperationalContextController {
|
||||
constructor(private readonly geometries: AssetGeometriesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('assets.read')
|
||||
map() {
|
||||
return this.geometries.mapOperationalContext();
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('map/documents')
|
||||
export class MapDocumentsController {
|
||||
constructor(private readonly geometries: AssetGeometriesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('assets.read', 'inspection_acts.read', 'inspection_findings.read')
|
||||
map() {
|
||||
return this.geometries.mapDocuments();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,8 @@ export class AssetGeometriesService {
|
||||
geometry: row.geometry,
|
||||
properties: {
|
||||
id: row.id,
|
||||
entityId: row.id,
|
||||
entityKind: 'ASSET',
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
typeId: row.typeId,
|
||||
@@ -242,12 +244,230 @@ export class AssetGeometriesService {
|
||||
accuracyM: row.accuracyM == null ? null : Number(row.accuracyM),
|
||||
capturedAt: row.capturedAt,
|
||||
updatedAt: row.updatedAt,
|
||||
href: `/inventarios/${row.id}`,
|
||||
contextLine: row.parentName ? `Depende de ${row.parentName}` : null,
|
||||
},
|
||||
})),
|
||||
meta: { count: visible.length, truncated },
|
||||
};
|
||||
}
|
||||
|
||||
async mapOperationalContext() {
|
||||
const rows = (await this.dataSource.query(`
|
||||
WITH RECURSIVE y_tree AS (
|
||||
SELECT y.id AS yacimiento_id, y.id AS asset_id
|
||||
FROM assets y
|
||||
INNER JOIN asset_types y_type ON y_type.id=y.asset_type_id
|
||||
WHERE lower(y_type.code)='yacimiento'
|
||||
AND y_type.is_active=true
|
||||
AND y.information_status<>'INACTIVE'
|
||||
UNION ALL
|
||||
SELECT tree.yacimiento_id, child.id
|
||||
FROM y_tree tree
|
||||
INNER JOIN assets child ON child.parent_id=tree.asset_id
|
||||
WHERE child.information_status<>'INACTIVE'
|
||||
), y_loc AS (
|
||||
SELECT tree.yacimiento_id,
|
||||
ST_Centroid(ST_Collect(geometry.geometry)) AS geometry,
|
||||
MAX(geometry.updated_at) AS updated_at,
|
||||
COUNT(*)::integer AS source_geometries
|
||||
FROM y_tree tree
|
||||
INNER JOIN asset_geometries geometry ON geometry.asset_id=tree.asset_id
|
||||
GROUP BY tree.yacimiento_id
|
||||
), base AS (
|
||||
SELECT
|
||||
y.id AS yacimiento_id,y.code AS yacimiento_code,y.name AS yacimiento_name,
|
||||
y.information_status AS yacimiento_status,
|
||||
area.id AS area_id,area.code AS area_code,area.name AS area_name,
|
||||
department.id AS department_id,department.code AS department_code,department.name AS department_name,
|
||||
company.id AS company_id,company.code AS company_code,COALESCE(profile.legal_name,company.name) AS company_name,
|
||||
loc.geometry,loc.updated_at,loc.source_geometries
|
||||
FROM y_loc loc
|
||||
INNER JOIN assets y ON y.id=loc.yacimiento_id
|
||||
LEFT JOIN assets area ON area.id=y.parent_id
|
||||
LEFT JOIN assets department ON department.id=area.parent_id
|
||||
LEFT JOIN assets company ON company.id=y.operator_company_id
|
||||
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
|
||||
)
|
||||
SELECT
|
||||
'YACIMIENTO:'||base.yacimiento_id::text AS "featureId",
|
||||
base.yacimiento_id AS "entityId",'YACIMIENTO'::text AS "entityKind",
|
||||
ST_AsGeoJSON(base.geometry)::jsonb AS geometry,
|
||||
base.yacimiento_code AS code,base.yacimiento_name AS name,
|
||||
'Yacimiento'::text AS "typeName",base.yacimiento_status::text AS "informationStatus",
|
||||
'POINT'::text AS "geometryType",base.updated_at AS "updatedAt",
|
||||
'/inventarios/'||base.yacimiento_id::text AS href,
|
||||
concat_ws(' · ',base.department_name,base.area_name,base.company_name) AS "contextLine",
|
||||
base.department_name AS "departmentName",base.area_name AS "areaName",
|
||||
base.yacimiento_name AS "yacimientoName",base.company_name AS "companyName",
|
||||
base.source_geometries AS "sourceGeometries"
|
||||
FROM base
|
||||
UNION ALL
|
||||
SELECT
|
||||
'COMPANY:'||base.company_id::text||':'||base.yacimiento_id::text AS "featureId",
|
||||
base.company_id AS "entityId",'COMPANY'::text AS "entityKind",
|
||||
ST_AsGeoJSON(base.geometry)::jsonb AS geometry,
|
||||
base.company_code AS code,base.company_name AS name,
|
||||
'Empresa operadora'::text AS "typeName",'ACTIVE'::text AS "informationStatus",
|
||||
'POINT'::text AS "geometryType",base.updated_at AS "updatedAt",
|
||||
'/inventarios/'||base.company_id::text AS href,
|
||||
concat_ws(' · ','Yacimiento '||base.yacimiento_name,'Área '||base.area_name,base.department_name) AS "contextLine",
|
||||
base.department_name AS "departmentName",base.area_name AS "areaName",
|
||||
base.yacimiento_name AS "yacimientoName",base.company_name AS "companyName",
|
||||
base.source_geometries AS "sourceGeometries"
|
||||
FROM base
|
||||
WHERE base.company_id IS NOT NULL
|
||||
ORDER BY "entityKind","name","code"
|
||||
`)) as Array<Record<string, unknown>>;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: rows.map((row) => ({
|
||||
type: 'Feature' as const,
|
||||
id: row.featureId,
|
||||
geometry: row.geometry,
|
||||
properties: {
|
||||
id: row.featureId,
|
||||
entityId: row.entityId,
|
||||
entityKind: row.entityKind,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
typeName: row.typeName,
|
||||
informationStatus: row.informationStatus,
|
||||
geometryType: row.geometryType,
|
||||
updatedAt: row.updatedAt,
|
||||
href: row.href,
|
||||
contextLine: row.contextLine,
|
||||
departmentName: row.departmentName,
|
||||
areaName: row.areaName,
|
||||
yacimientoName: row.yacimientoName,
|
||||
companyName: row.companyName,
|
||||
sourceGeometries: row.sourceGeometries,
|
||||
},
|
||||
})),
|
||||
meta: { count: rows.length, truncated: false },
|
||||
};
|
||||
}
|
||||
|
||||
async mapDocuments() {
|
||||
const rows = (await this.dataSource.query(`
|
||||
WITH RECURSIVE y_tree AS (
|
||||
SELECT y.id AS yacimiento_id, y.id AS asset_id
|
||||
FROM assets y
|
||||
INNER JOIN asset_types y_type ON y_type.id=y.asset_type_id
|
||||
WHERE lower(y_type.code)='yacimiento'
|
||||
AND y_type.is_active=true
|
||||
AND y.information_status<>'INACTIVE'
|
||||
UNION ALL
|
||||
SELECT tree.yacimiento_id, child.id
|
||||
FROM y_tree tree
|
||||
INNER JOIN assets child ON child.parent_id=tree.asset_id
|
||||
WHERE child.information_status<>'INACTIVE'
|
||||
), y_loc AS (
|
||||
SELECT tree.yacimiento_id,
|
||||
ST_Centroid(ST_Collect(geometry.geometry)) AS geometry,
|
||||
MAX(geometry.updated_at) AS updated_at
|
||||
FROM y_tree tree
|
||||
INNER JOIN asset_geometries geometry ON geometry.asset_id=tree.asset_id
|
||||
GROUP BY tree.yacimiento_id
|
||||
), finding_rows AS (
|
||||
SELECT
|
||||
finding.id,finding.code,finding.title,finding.status,finding.asset_id,
|
||||
act.id AS act_id,act.code AS act_code,act.occurred_at,
|
||||
visit.id AS visit_id,visit.code AS visit_code,
|
||||
target.code AS asset_code,target.name AS asset_name,
|
||||
target_geometry.geometry AS target_geometry,
|
||||
tree.yacimiento_id,
|
||||
y.name AS yacimiento_name,area.name AS area_name,department.name AS department_name,
|
||||
COALESCE(profile.legal_name,company.name) AS company_name,
|
||||
loc.geometry AS fallback_geometry,COALESCE(target_geometry.updated_at,loc.updated_at) AS updated_at
|
||||
FROM inspection_findings finding
|
||||
INNER JOIN inspection_acts act ON act.id=finding.act_id
|
||||
INNER JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||
INNER JOIN assets target ON target.id=finding.asset_id
|
||||
LEFT JOIN asset_geometries target_geometry ON target_geometry.asset_id=target.id
|
||||
LEFT JOIN y_tree tree ON tree.asset_id=target.id
|
||||
LEFT JOIN assets y ON y.id=tree.yacimiento_id
|
||||
LEFT JOIN assets area ON area.id=y.parent_id
|
||||
LEFT JOIN assets department ON department.id=area.parent_id
|
||||
LEFT JOIN assets company ON company.id=y.operator_company_id
|
||||
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
|
||||
LEFT JOIN y_loc loc ON loc.yacimiento_id=tree.yacimiento_id
|
||||
WHERE finding.status<>'VOIDED'
|
||||
), act_yacimiento AS (
|
||||
SELECT act.id AS act_id,
|
||||
COALESCE(
|
||||
CASE WHEN lower(COALESCE(scope_type.code,''))='yacimiento' THEN scope.id END,
|
||||
(SELECT fr.yacimiento_id FROM finding_rows fr WHERE fr.act_id=act.id AND fr.yacimiento_id IS NOT NULL ORDER BY fr.id LIMIT 1)
|
||||
) AS yacimiento_id
|
||||
FROM inspection_acts act
|
||||
INNER JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||
LEFT JOIN assets scope ON scope.id=visit.scope_asset_id
|
||||
LEFT JOIN asset_types scope_type ON scope_type.id=scope.asset_type_id
|
||||
WHERE act.status<>'CANCELLED'
|
||||
)
|
||||
SELECT
|
||||
'FINDING:'||finding.id::text AS "featureId",finding.id AS "entityId",'FINDING'::text AS "entityKind",
|
||||
ST_AsGeoJSON(ST_Centroid(COALESCE(finding.target_geometry,finding.fallback_geometry)))::jsonb AS geometry,
|
||||
finding.code,finding.title AS name,'Hallazgo'::text AS "typeName",finding.status::text AS "informationStatus",
|
||||
'POINT'::text AS "geometryType",finding.updated_at AS "updatedAt",
|
||||
'/hallazgos/'||finding.id::text AS href,
|
||||
concat_ws(' · ',finding.act_code,finding.asset_name,finding.yacimiento_name,finding.company_name) AS "contextLine",
|
||||
finding.department_name AS "departmentName",finding.area_name AS "areaName",
|
||||
finding.yacimiento_name AS "yacimientoName",finding.company_name AS "companyName",
|
||||
finding.act_code AS "actCode",finding.asset_name AS "assetName"
|
||||
FROM finding_rows finding
|
||||
WHERE COALESCE(finding.target_geometry,finding.fallback_geometry) IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
'ACT:'||act.id::text AS "featureId",act.id AS "entityId",'ACT'::text AS "entityKind",
|
||||
ST_AsGeoJSON(loc.geometry)::jsonb AS geometry,
|
||||
act.code,act.title AS name,'Acta'::text AS "typeName",act.status::text AS "informationStatus",
|
||||
'POINT'::text AS "geometryType",COALESCE(loc.updated_at,act.updated_at) AS "updatedAt",
|
||||
'/inspecciones/actas/'||act.id::text AS href,
|
||||
concat_ws(' · ',y.name,area.name,COALESCE(profile.legal_name,company.name)) AS "contextLine",
|
||||
department.name AS "departmentName",area.name AS "areaName",y.name AS "yacimientoName",
|
||||
COALESCE(profile.legal_name,company.name) AS "companyName",act.code AS "actCode",NULL::text AS "assetName"
|
||||
FROM inspection_acts act
|
||||
INNER JOIN act_yacimiento ay ON ay.act_id=act.id
|
||||
INNER JOIN y_loc loc ON loc.yacimiento_id=ay.yacimiento_id
|
||||
INNER JOIN assets y ON y.id=ay.yacimiento_id
|
||||
LEFT JOIN assets area ON area.id=y.parent_id
|
||||
LEFT JOIN assets department ON department.id=area.parent_id
|
||||
LEFT JOIN assets company ON company.id=y.operator_company_id
|
||||
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
|
||||
WHERE act.status<>'CANCELLED'
|
||||
ORDER BY "entityKind","code"
|
||||
`)) as Array<Record<string, unknown>>;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: rows.map((row) => ({
|
||||
type: 'Feature' as const,
|
||||
id: row.featureId,
|
||||
geometry: row.geometry,
|
||||
properties: {
|
||||
id: row.featureId,
|
||||
entityId: row.entityId,
|
||||
entityKind: row.entityKind,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
typeName: row.typeName,
|
||||
informationStatus: row.informationStatus,
|
||||
geometryType: row.geometryType,
|
||||
updatedAt: row.updatedAt,
|
||||
href: row.href,
|
||||
contextLine: row.contextLine,
|
||||
departmentName: row.departmentName,
|
||||
areaName: row.areaName,
|
||||
yacimientoName: row.yacimientoName,
|
||||
companyName: row.companyName,
|
||||
actCode: row.actCode,
|
||||
assetName: row.assetName,
|
||||
},
|
||||
})),
|
||||
meta: { count: rows.length, truncated: false },
|
||||
};
|
||||
}
|
||||
|
||||
private async requireAsset(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
|
||||
@@ -7,6 +7,8 @@ import { AssetsService } from './assets.service';
|
||||
import {
|
||||
AssetGeometriesController,
|
||||
MapAssetsController,
|
||||
MapDocumentsController,
|
||||
MapOperationalContextController,
|
||||
} from './asset-geometries.controller';
|
||||
import { AssetGeometriesService } from './asset-geometries.service';
|
||||
import { AssetHistoryController } from './asset-history.controller';
|
||||
@@ -50,6 +52,8 @@ import { InventoryBrowserService } from './inventory-browser.service';
|
||||
FieldInventoryMergeController,
|
||||
AssetGeometriesController,
|
||||
MapAssetsController,
|
||||
MapOperationalContextController,
|
||||
MapDocumentsController,
|
||||
AssetHistoryController,
|
||||
AssetMediaController,
|
||||
AssetProvenanceController,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class F612AdminReportPermission1790142600000 implements MigrationInterface {
|
||||
name = 'F612AdminReportPermission1790142600000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
INSERT INTO role_permissions(role_id, permission_id)
|
||||
SELECT role.id, permission.id
|
||||
FROM roles role
|
||||
JOIN permissions permission ON permission.code='inspection_reports.generate'
|
||||
WHERE role.code='admin'
|
||||
ON CONFLICT(role_id, permission_id) DO NOTHING
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM role_permissions role_permission
|
||||
USING roles role, permissions permission
|
||||
WHERE role_permission.role_id=role.id
|
||||
AND role_permission.permission_id=permission.id
|
||||
AND role.code='admin'
|
||||
AND permission.code='inspection_reports.generate'
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -59,9 +59,22 @@ interface ActReportSummary {
|
||||
code: string;
|
||||
status: string;
|
||||
pdfStatus: string;
|
||||
wordStatus: string;
|
||||
gedoIfIdentifier: string | null;
|
||||
gedoOfficializedAt: Date | null;
|
||||
generatedAt: Date;
|
||||
}
|
||||
|
||||
interface ActTerritorialContext {
|
||||
department: ActContextAsset | null;
|
||||
area: ActContextAsset | null;
|
||||
yacimiento: ActContextAsset | null;
|
||||
company: ActContextAsset | null;
|
||||
installations: ActContextAsset[];
|
||||
subinstallations: ActContextAsset[];
|
||||
legacyAreaScope: boolean;
|
||||
}
|
||||
|
||||
export interface InspectionActListItem {
|
||||
id: string;
|
||||
visitId: string;
|
||||
@@ -94,6 +107,7 @@ export interface InspectionActListItem {
|
||||
findingCount: number;
|
||||
companies: ActContextAsset[];
|
||||
areas: ActContextAsset[];
|
||||
context: ActTerritorialContext;
|
||||
report: ActReportSummary | null;
|
||||
createdBy: ActPerson | null;
|
||||
updatedBy: ActPerson | null;
|
||||
@@ -547,11 +561,23 @@ export class InspectionActsService {
|
||||
COALESCE(finding_count.total, 0)::integer AS "findingCount",
|
||||
COALESCE(context.companies, '[]'::jsonb) AS companies,
|
||||
COALESCE(context.areas, '[]'::jsonb) AS areas,
|
||||
JSONB_BUILD_OBJECT(
|
||||
'department', context.department,
|
||||
'area', context.area,
|
||||
'yacimiento', context.yacimiento,
|
||||
'company', context.company,
|
||||
'installations', COALESCE(context.installations, '[]'::jsonb),
|
||||
'subinstallations', COALESCE(context.subinstallations, '[]'::jsonb),
|
||||
'legacyAreaScope', context.legacy_area_scope
|
||||
) AS context,
|
||||
CASE WHEN report.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', report.id,
|
||||
'code', report.code,
|
||||
'status', report.status,
|
||||
'pdfStatus', report.pdf_status,
|
||||
'wordStatus', report.word_status,
|
||||
'gedoIfIdentifier', report.gedo_if_identifier,
|
||||
'gedoOfficializedAt', report.gedo_officialized_at,
|
||||
'generatedAt', report.generated_at
|
||||
) END AS report,
|
||||
CASE WHEN creator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
@@ -580,10 +606,64 @@ export class InspectionActsService {
|
||||
) END AS companies,
|
||||
CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
|
||||
JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name)
|
||||
) END AS areas
|
||||
) END AS areas,
|
||||
CASE WHEN department.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',department.id,'code',department.code,'name',department.name
|
||||
) END AS department,
|
||||
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',area.id,'code',area.code,'name',area.name
|
||||
) END AS area,
|
||||
CASE WHEN yacimiento.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',yacimiento.id,'code',yacimiento.code,'name',yacimiento.name
|
||||
) END AS yacimiento,
|
||||
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',company.id,'code',company.code,'name',company.name
|
||||
) END AS company,
|
||||
COALESCE(affected.installations, '[]'::jsonb) AS installations,
|
||||
COALESCE(affected.subinstallations, '[]'::jsonb) AS subinstallations,
|
||||
(lower(COALESCE(scope_type.code,''))='area') AS legacy_area_scope
|
||||
FROM inspection_visits context_visit
|
||||
LEFT JOIN assets company ON company.id=context_visit.operator_company_id
|
||||
LEFT JOIN assets area ON area.id=context_visit.operational_area_id
|
||||
LEFT JOIN assets department ON department.id=area.parent_id
|
||||
LEFT JOIN assets scope_asset ON scope_asset.id=context_visit.scope_asset_id
|
||||
LEFT JOIN asset_types scope_type ON scope_type.id=scope_asset.asset_type_id
|
||||
LEFT JOIN assets yacimiento ON yacimiento.id=CASE
|
||||
WHEN lower(COALESCE(scope_type.code,''))='yacimiento' THEN scope_asset.id
|
||||
ELSE NULL
|
||||
END
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',x.id,'code',x.code,'name',x.name) ORDER BY x.name,x.code)
|
||||
FROM (
|
||||
SELECT DISTINCT installation.id, installation.code, installation.name
|
||||
FROM inspection_findings finding_context
|
||||
INNER JOIN assets finding_asset ON finding_asset.id=finding_context.asset_id
|
||||
INNER JOIN asset_types finding_type ON finding_type.id=finding_asset.asset_type_id
|
||||
LEFT JOIN assets installation ON installation.id=CASE
|
||||
WHEN lower(finding_type.code)='instalacion' THEN finding_asset.id
|
||||
WHEN lower(finding_type.code)='subinstalacion' THEN finding_asset.parent_id
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE finding_context.act_id=act.id
|
||||
AND finding_context.status<>'VOIDED'
|
||||
AND installation.id IS NOT NULL
|
||||
) x
|
||||
), '[]'::jsonb) AS installations,
|
||||
COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',x.id,'code',x.code,'name',x.name) ORDER BY x.name,x.code)
|
||||
FROM (
|
||||
SELECT DISTINCT finding_asset.id, finding_asset.code, finding_asset.name
|
||||
FROM inspection_findings finding_context
|
||||
INNER JOIN assets finding_asset ON finding_asset.id=finding_context.asset_id
|
||||
INNER JOIN asset_types finding_type ON finding_type.id=finding_asset.asset_type_id
|
||||
WHERE finding_context.act_id=act.id
|
||||
AND finding_context.status<>'VOIDED'
|
||||
AND lower(finding_type.code)='subinstalacion'
|
||||
) x
|
||||
), '[]'::jsonb) AS subinstallations
|
||||
) affected ON true
|
||||
WHERE context_visit.id=act.visit_id
|
||||
) context ON true
|
||||
LEFT JOIN LATERAL (
|
||||
|
||||
@@ -41,9 +41,18 @@ interface FindingAssetView {
|
||||
code: string;
|
||||
name: string;
|
||||
commonName: string | null;
|
||||
typeCode: string;
|
||||
typeName: string;
|
||||
operatorCompany: FindingContextView | null;
|
||||
operationalArea: FindingContextView | null;
|
||||
hierarchy: {
|
||||
department: FindingContextView | null;
|
||||
area: FindingContextView | null;
|
||||
yacimiento: FindingContextView | null;
|
||||
installation: FindingContextView | null;
|
||||
subinstallation: FindingContextView | null;
|
||||
company: FindingContextView | null;
|
||||
};
|
||||
}
|
||||
|
||||
type FindingCatalogView = {
|
||||
@@ -809,13 +818,34 @@ export class InspectionFindingsService {
|
||||
'code', asset.code,
|
||||
'name', asset.name,
|
||||
'commonName', asset.common_name,
|
||||
'typeCode', asset_type.code,
|
||||
'typeName', asset_type.name,
|
||||
'operatorCompany', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', company.id, 'code', company.code, 'name', company.name
|
||||
) END,
|
||||
'operationalArea', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', area.id, 'code', area.code, 'name', area.name
|
||||
) END
|
||||
) END,
|
||||
'hierarchy', JSONB_BUILD_OBJECT(
|
||||
'department', CASE WHEN department.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',department.id,'code',department.code,'name',department.name
|
||||
) END,
|
||||
'area', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',area.id,'code',area.code,'name',area.name
|
||||
) END,
|
||||
'yacimiento', CASE WHEN yacimiento.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',yacimiento.id,'code',yacimiento.code,'name',yacimiento.name
|
||||
) END,
|
||||
'installation', CASE WHEN installation.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',installation.id,'code',installation.code,'name',installation.name
|
||||
) END,
|
||||
'subinstallation', CASE WHEN subinstallation.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',subinstallation.id,'code',subinstallation.code,'name',subinstallation.name
|
||||
) END,
|
||||
'company', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',company.id,'code',company.code,'name',company.name
|
||||
) END
|
||||
)
|
||||
) AS asset,
|
||||
CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', catalog.id,
|
||||
@@ -861,6 +891,24 @@ export class InspectionFindingsService {
|
||||
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||
LEFT JOIN assets company ON company.id = asset.operator_company_id
|
||||
LEFT JOIN assets area ON area.id = asset.operational_area_id
|
||||
LEFT JOIN assets department ON department.id = area.parent_id
|
||||
LEFT JOIN assets parent_asset ON parent_asset.id = asset.parent_id
|
||||
LEFT JOIN assets grandparent_asset ON grandparent_asset.id = parent_asset.parent_id
|
||||
LEFT JOIN assets yacimiento ON yacimiento.id = CASE
|
||||
WHEN lower(asset_type.code)='yacimiento' THEN asset.id
|
||||
WHEN lower(asset_type.code)='instalacion' THEN parent_asset.id
|
||||
WHEN lower(asset_type.code)='subinstalacion' THEN grandparent_asset.id
|
||||
ELSE NULL
|
||||
END
|
||||
LEFT JOIN assets installation ON installation.id = CASE
|
||||
WHEN lower(asset_type.code)='instalacion' THEN asset.id
|
||||
WHEN lower(asset_type.code)='subinstalacion' THEN parent_asset.id
|
||||
ELSE NULL
|
||||
END
|
||||
LEFT JOIN assets subinstallation ON subinstallation.id = CASE
|
||||
WHEN lower(asset_type.code)='subinstalacion' THEN asset.id
|
||||
ELSE NULL
|
||||
END
|
||||
LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id
|
||||
LEFT JOIN finding_categories category ON category.id = catalog.category_id
|
||||
LEFT JOIN LATERAL (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { connect as connectNet, Socket } from 'node:net';
|
||||
import { connect as connectTls, TLSSocket } from 'node:tls';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { SmtpSecurityMode } from '../database/entities';
|
||||
@@ -250,9 +250,9 @@ export class SmtpDeliveryService {
|
||||
|
||||
private encryptionKey():Buffer{
|
||||
const raw=this.config.get<string>('SMTP_SETTINGS_MASTER_KEY');
|
||||
if(!raw)throw new Error('SMTP_SETTINGS_MASTER_KEY no configurada');
|
||||
if(!raw)throw new ServiceUnavailableException({ code:'SMTP_SETTINGS_MASTER_KEY_NOT_CONFIGURED', message:'La clave maestra para proteger credenciales SMTP no está configurada en el servidor' });
|
||||
const key=/^[0-9a-fA-F]{64}$/.test(raw)?Buffer.from(raw,'hex'):Buffer.from(raw,'base64');
|
||||
if(key.length!==32)throw new Error('SMTP_SETTINGS_MASTER_KEY debe contener exactamente 32 bytes');
|
||||
if(key.length!==32)throw new ServiceUnavailableException({ code:'SMTP_SETTINGS_MASTER_KEY_INVALID', message:'La clave maestra SMTP del servidor tiene un formato inválido' });
|
||||
return key;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class OpenMobileInspectionDto {
|
||||
@IsUUID('4')
|
||||
departmentId!: string;
|
||||
|
||||
@IsUUID('4')
|
||||
operationalAreaId!: string;
|
||||
|
||||
@IsUUID('4')
|
||||
scopeAssetId!: string;
|
||||
|
||||
@IsUUID('4')
|
||||
operatorCompanyId!: string;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,67 @@ export class InspectionPlanningHierarchyService {
|
||||
return { data };
|
||||
}
|
||||
|
||||
async operatorsForYacimiento(yacimientoId: string): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
|
||||
await this.requireType(yacimientoId, 'yacimiento', 'El Yacimiento seleccionado no es válido');
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT company.id, company.code, COALESCE(profile.legal_name,company.name) AS name
|
||||
FROM assets yacimiento
|
||||
INNER JOIN assets company ON company.id=yacimiento.operator_company_id
|
||||
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
|
||||
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
|
||||
WHERE yacimiento.id=$1::uuid
|
||||
AND yacimiento.information_status<>'INACTIVE'
|
||||
AND company_type.operational_role='COMPANY'
|
||||
AND company_type.is_active=true
|
||||
AND company.information_status<>'INACTIVE'
|
||||
ORDER BY name, company.code
|
||||
`, [yacimientoId]) as InspectionPlanningHierarchyItem[];
|
||||
return { data };
|
||||
}
|
||||
|
||||
async validateMobileSelection(
|
||||
departmentId: string,
|
||||
areaId: string,
|
||||
yacimientoId: string,
|
||||
companyId: string,
|
||||
): Promise<void> {
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT 1
|
||||
FROM assets department
|
||||
INNER JOIN asset_types department_type ON department_type.id=department.asset_type_id
|
||||
INNER JOIN assets area ON area.parent_id=department.id
|
||||
INNER JOIN asset_types area_type ON area_type.id=area.asset_type_id
|
||||
INNER JOIN assets yacimiento ON yacimiento.parent_id=area.id
|
||||
INNER JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
|
||||
INNER JOIN assets company ON company.id=yacimiento.operator_company_id
|
||||
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
|
||||
WHERE department.id=$1::uuid
|
||||
AND area.id=$2::uuid
|
||||
AND yacimiento.id=$3::uuid
|
||||
AND company.id=$4::uuid
|
||||
AND lower(department_type.code)='departamento'
|
||||
AND lower(area_type.code)='area'
|
||||
AND lower(yacimiento_type.code)='yacimiento'
|
||||
AND department_type.is_active=true
|
||||
AND area_type.is_active=true
|
||||
AND yacimiento_type.is_active=true
|
||||
AND company_type.operational_role='COMPANY'
|
||||
AND company_type.is_active=true
|
||||
AND department.information_status<>'INACTIVE'
|
||||
AND area.information_status<>'INACTIVE'
|
||||
AND yacimiento.information_status<>'INACTIVE'
|
||||
AND company.information_status<>'INACTIVE'
|
||||
AND yacimiento.concession_type_id IS NOT NULL
|
||||
LIMIT 1
|
||||
`, [departmentId, areaId, yacimientoId, companyId]) as Array<{ '?column?': number }>;
|
||||
if (!row) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_MOBILE_CONTEXT_INVALID',
|
||||
message: 'La selección debe respetar Departamento → Área → Yacimiento → Operadora',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async operatorsForArea(
|
||||
areaId: string,
|
||||
at?: string,
|
||||
|
||||
@@ -87,21 +87,41 @@ export class InspectionVisitsController {
|
||||
return this.planningHierarchy.operatorsForArea(areaId, at);
|
||||
}
|
||||
|
||||
@Get('mobile/planning-context/areas')
|
||||
@Get('mobile/planning-context/departments')
|
||||
@RequirePermissions('inspections.execute')
|
||||
mobilePlanningAreas(@CurrentAuth() principal: AuthPrincipal) {
|
||||
mobilePlanningDepartments(@CurrentAuth() principal: AuthPrincipal) {
|
||||
assertMobileInspector(principal);
|
||||
return this.visits.listPlanningAreas();
|
||||
return this.planningHierarchy.departments();
|
||||
}
|
||||
|
||||
@Get('mobile/planning-context/areas/:areaId/operators')
|
||||
@Get('mobile/planning-context/departments/:departmentId/areas')
|
||||
@RequirePermissions('inspections.execute')
|
||||
mobilePlanningOperators(
|
||||
mobilePlanningAreasForDepartment(
|
||||
@Param('departmentId', new ParseUUIDPipe({ version: '4' })) departmentId: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
) {
|
||||
assertMobileInspector(principal);
|
||||
return this.planningHierarchy.areasForDepartment(departmentId);
|
||||
}
|
||||
|
||||
@Get('mobile/planning-context/areas/:areaId/yacimientos')
|
||||
@RequirePermissions('inspections.execute')
|
||||
mobilePlanningYacimientos(
|
||||
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
) {
|
||||
assertMobileInspector(principal);
|
||||
return this.visits.listPlanningOperators(areaId);
|
||||
return this.planningHierarchy.yacimientosForArea(areaId);
|
||||
}
|
||||
|
||||
@Get('mobile/planning-context/yacimientos/:yacimientoId/operators')
|
||||
@RequirePermissions('inspections.execute')
|
||||
mobilePlanningOperatorsForYacimiento(
|
||||
@Param('yacimientoId', new ParseUUIDPipe({ version: '4' })) yacimientoId: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
) {
|
||||
assertMobileInspector(principal);
|
||||
return this.planningHierarchy.operatorsForYacimiento(yacimientoId);
|
||||
}
|
||||
|
||||
@Post('mobile/open')
|
||||
@@ -112,10 +132,15 @@ export class InspectionVisitsController {
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
assertMobileInspector(principal);
|
||||
// Android conserva por compatibilidad el alcance a nivel Área hasta que su
|
||||
// flujo también solicite Yacimiento. No se mezcla con la creación WEB F6.1.
|
||||
const created = await this.visits.create({
|
||||
await this.planningHierarchy.validateMobileSelection(
|
||||
dto.departmentId,
|
||||
dto.operationalAreaId,
|
||||
dto.scopeAssetId,
|
||||
dto.operatorCompanyId,
|
||||
);
|
||||
const created = await this.planningCreate.create({
|
||||
operationalAreaId: dto.operationalAreaId,
|
||||
scopeAssetId: dto.scopeAssetId,
|
||||
operatorCompanyId: dto.operatorCompanyId,
|
||||
plannedStartAt: new Date().toISOString(),
|
||||
leadInspectorUserId: principal.userId,
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const API_VERSION = '0.29.0-12';
|
||||
export const API_PHASE = 'F6.11';
|
||||
export const API_VERSION = '0.29.0-15';
|
||||
export const API_PHASE = 'F6.15';
|
||||
|
||||
@@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { API_PHASE, API_VERSION } from '../../src/version';
|
||||
|
||||
test('health metadata reports the current F6.11 release', () => {
|
||||
assert.equal(API_PHASE, 'F6.11');
|
||||
test('health metadata reports the current F6.15 release', () => {
|
||||
assert.equal(API_PHASE, 'F6.15');
|
||||
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
|
||||
assert.equal(API_VERSION, pkg.version);
|
||||
assert.equal(API_VERSION, '0.29.0-12');
|
||||
assert.equal(API_VERSION, '0.29.0-15');
|
||||
});
|
||||
|
||||
@@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string {
|
||||
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
|
||||
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
|
||||
|
||||
assert.match(gradle, /versionCode = 39/);
|
||||
assert.match(gradle, /versionName = "0\.19\.11"/);
|
||||
assert.match(gradle, /versionCode = 41/);
|
||||
assert.match(gradle, /versionName = "0\.19\.13"/);
|
||||
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
||||
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
|
||||
});
|
||||
|
||||
@@ -36,33 +36,48 @@ test('F6.1 uses contextual type catalog for Yacimiento and keeps OTROS/add-anoth
|
||||
assert.match(androidFinding, /También podés registrar otro Hallazgo sobre el mismo Inventario/);
|
||||
});
|
||||
|
||||
test('F6.1 exposes mobile planning context and opens an inspection self-assigned to the current inspector', () => {
|
||||
test('F6.14 mobile planning requires Departamento → Área → Yacimiento → Operadora before opening', () => {
|
||||
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
|
||||
const hierarchy = source('src/inspection-visits/inspection-planning-hierarchy.service.ts');
|
||||
const dto = source('src/inspection-visits/dto/open-mobile-inspection.dto.ts');
|
||||
|
||||
assert.match(controller, /@Get\('mobile\/planning-context\/areas'\)/);
|
||||
assert.match(controller, /@Get\('mobile\/planning-context\/areas\/:areaId\/operators'\)/);
|
||||
assert.match(controller, /@Get\('mobile\/planning-context\/departments'\)/);
|
||||
assert.match(controller, /@Get\('mobile\/planning-context\/departments\/:departmentId\/areas'\)/);
|
||||
assert.match(controller, /@Get\('mobile\/planning-context\/areas\/:areaId\/yacimientos'\)/);
|
||||
assert.match(controller, /@Get\('mobile\/planning-context\/yacimientos\/:yacimientoId\/operators'\)/);
|
||||
assert.match(controller, /@Post\('mobile\/open'\)/);
|
||||
assert.match(controller, /@RequirePermissions\('inspections\.execute'\)/);
|
||||
assert.match(controller, /assertMobileInspector\(principal\)/);
|
||||
assert.match(controller, /validateMobileSelection/);
|
||||
assert.match(controller, /scopeAssetId: dto\.scopeAssetId/);
|
||||
assert.match(controller, /leadInspectorUserId: principal\.userId/);
|
||||
assert.match(controller, /plannedStartAt: new Date\(\)\.toISOString\(\)/);
|
||||
assert.match(controller, /this\.lifecycle\.plan\(created\.id/);
|
||||
assert.match(controller, /this\.lifecycle\.start\(created\.id/);
|
||||
assert.match(hierarchy, /Departamento → Área → Yacimiento → Operadora/);
|
||||
assert.match(dto, /departmentId!: string/);
|
||||
assert.match(dto, /operationalAreaId!: string/);
|
||||
assert.match(dto, /scopeAssetId!: string/);
|
||||
assert.match(dto, /operatorCompanyId!: string/);
|
||||
});
|
||||
|
||||
test('F6.1 Android lets the inspector choose Area and current Operator and open the inspection now', () => {
|
||||
test('F6.14 Android selects the full territorial chain and enters Actas for an active inspection', () => {
|
||||
const client = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt');
|
||||
const home = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt');
|
||||
const root = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt');
|
||||
const acts = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt');
|
||||
const gate = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt');
|
||||
|
||||
assert.match(client, /inspection-visits\/mobile\/planning-context\/areas/);
|
||||
assert.match(client, /inspection-visits\/mobile\/open/);
|
||||
assert.match(home, /Abrir inspección/);
|
||||
assert.match(home, /Abrir inspección ahora/);
|
||||
assert.match(home, /Operadora vigente/);
|
||||
assert.match(client, /planning-context\/departments/);
|
||||
assert.match(client, /departments\/\{departmentId\}\/areas/);
|
||||
assert.match(client, /areas\/\{areaId\}\/yacimientos/);
|
||||
assert.match(client, /yacimientos\/\{yacimientoId\}\/operators/);
|
||||
assert.match(client, /OpenMobileInspectionRequest\(departmentId, areaId, yacimientoId, companyId\)/);
|
||||
assert.match(home, /1\. Departamento/);
|
||||
assert.match(home, /2\. Área/);
|
||||
assert.match(home, /3\. Yacimiento/);
|
||||
assert.match(home, /4\. Operadora/);
|
||||
assert.match(home, /Abrir inspección y continuar al Acta/);
|
||||
assert.match(home, /model\.openVisit\(opened\.id\)/);
|
||||
assert.match(root, /if \(visit\.status == "IN_PROGRESS"\) ModernVisitScreen\.ACTS/);
|
||||
assert.match(root, /Buscar Yacimiento, Instalación o Subinstalación/);
|
||||
assert.match(acts, /Elegí el Yacimiento, una Instalación o una Subinstalación/);
|
||||
assert.match(gate, /else -> MobileHomeScreen\(model\)/);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack
|
||||
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
|
||||
|
||||
assert.equal(visibleVersion, pkg.version);
|
||||
assert.match(version, /APP_PHASE\s*=\s*'F6\.11/);
|
||||
assert.match(version, /APP_PHASE\s*=\s*'F6\.15/);
|
||||
});
|
||||
|
||||
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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.12 uses standard GeoJSON names in WEB and converts only at the API boundary', () => {
|
||||
const client = web('src/lib/api.ts');
|
||||
const map = web('src/features/map/DhMap.tsx');
|
||||
const editor = web('src/features/map/AssetGeometryEditor.tsx');
|
||||
assert.match(client, /type: 'Point'/);
|
||||
assert.match(client, /type: 'LineString'/);
|
||||
assert.match(client, /type: 'Polygon'/);
|
||||
assert.match(client, /geometryPayload/);
|
||||
assert.match(map, /feature\.geometry\.type === 'Point'/);
|
||||
assert.match(editor, /setType\(value\.geometryType\)/);
|
||||
});
|
||||
|
||||
test('F6.12 reports missing SMTP encryption configuration explicitly', () => {
|
||||
const smtp = api('src/inspection-reports/smtp-delivery.service.ts');
|
||||
assert.match(smtp, /SMTP_SETTINGS_MASTER_KEY_NOT_CONFIGURED/);
|
||||
assert.match(smtp, /ServiceUnavailableException/);
|
||||
});
|
||||
|
||||
test('F6.12 grants report management to the system administrator role', () => {
|
||||
const migration = api('src/database/migrations/1790142600000-f6-12-admin-report-permission.ts');
|
||||
assert.match(migration, /role\.code='admin'/);
|
||||
assert.match(migration, /inspection_reports\.generate/);
|
||||
assert.match(migration, /ON CONFLICT\(role_id, permission_id\) DO NOTHING/);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
function api(path: string) { return readFileSync(resolve(process.cwd(), path), 'utf8'); }
|
||||
function web(path: string) { return readFileSync(resolve(process.cwd(), `../web-v2/src/${path}`), 'utf8'); }
|
||||
|
||||
test('F6.15 Acta exposes full territorial and affected-inventory context', () => {
|
||||
const acts = api('src/inspection-acts/inspection-acts.service.ts');
|
||||
const findings = api('src/inspection-findings/inspection-findings.service.ts');
|
||||
const page = web('pages/InspectionActEditorPage.tsx');
|
||||
const media = web('features/inspections/InspectionActMediaPanel.tsx');
|
||||
for (const field of ['department', 'area', 'yacimiento', 'company', 'installations', 'subinstallations']) {
|
||||
assert.match(acts, new RegExp(`'${field}'`));
|
||||
}
|
||||
assert.match(acts, /legacyAreaScope/);
|
||||
assert.match(findings, /'hierarchy'/);
|
||||
assert.match(findings, /'installation'/);
|
||||
assert.match(findings, /'subinstallation'/);
|
||||
assert.match(page, /Ubicación territorial y operativa/);
|
||||
assert.match(page, /No definido en la Inspección histórica/);
|
||||
assert.match(media, /Elemento afectado:/);
|
||||
assert.match(media, /Constatación:/);
|
||||
});
|
||||
|
||||
test('F6.15 Acta uses real report workflow state instead of legacy generated PDF status', () => {
|
||||
const acts = api('src/inspection-acts/inspection-acts.service.ts');
|
||||
const page = web('pages/InspectionActEditorPage.tsx');
|
||||
const list = web('pages/ActsPage.tsx');
|
||||
assert.match(acts, /'gedoIfIdentifier'/);
|
||||
assert.match(acts, /'gedoOfficializedAt'/);
|
||||
assert.match(acts, /'wordStatus'/);
|
||||
assert.match(page, /act\.report\.status === 'OFFICIALIZED'/);
|
||||
assert.match(page, /Oficializado en GEDO/);
|
||||
assert.doesNotMatch(page, /act\.report\.pdfStatus === 'READY'/);
|
||||
assert.match(list, /act\.report\.status === 'OFFICIALIZED'/);
|
||||
});
|
||||
|
||||
test('F6.15 map exposes filtered operational layers without inventing coordinates', () => {
|
||||
const controller = api('src/asset-master/asset-geometries.controller.ts');
|
||||
const service = api('src/asset-master/asset-geometries.service.ts');
|
||||
const page = web('pages/MapPage.tsx');
|
||||
const map = web('features/map/DhMap.tsx');
|
||||
assert.match(controller, /@Controller\('map\/context'\)/);
|
||||
assert.match(controller, /@Controller\('map\/documents'\)/);
|
||||
assert.match(service, /WITH RECURSIVE y_tree/);
|
||||
assert.match(service, /ST_Centroid\(ST_Collect\(geometry\.geometry\)\)/);
|
||||
assert.match(service, /COALESCE\(finding\.target_geometry,finding\.fallback_geometry\)/);
|
||||
for (const kind of ['YACIMIENTO', 'COMPANY', 'ACT', 'FINDING']) {
|
||||
assert.match(service, new RegExp(`'${kind}'::text`));
|
||||
assert.match(page, new RegExp(`${kind}:`));
|
||||
}
|
||||
assert.match(page, /Buscar Yacimiento, Empresa, Acta, Hallazgo/);
|
||||
assert.match(page, /sourceGeometries/);
|
||||
assert.match(map, /yacimiento-points/);
|
||||
assert.match(map, /company-points/);
|
||||
assert.match(map, /act-points/);
|
||||
assert.match(map, /finding-points/);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
# F6.12 · Geometrías y correo SMTP
|
||||
|
||||
- Corrige el contrato GeoJSON entre PostGIS, WEB y MapLibre: `Point`, `LineString` y `Polygon` se usan para render; `POINT`, `LINESTRING` y `POLYGON` quedan como tipos técnicos al persistir.
|
||||
- El editor de geometrías vuelve a cargar correctamente geometrías existentes y conserva el tipo técnico separado del GeoJSON.
|
||||
- La vista general del mapa vuelve a calcular bounds sobre GeoJSON real.
|
||||
- El SMTP administrable requiere una clave maestra AES-256-GCM persistente; si falta o es inválida, el API devuelve un error de configuración explícito en vez de un 500 genérico.
|
||||
- El rol Administrador recibe `inspection_reports.generate`, coherente con su definición de administración total.
|
||||
@@ -0,0 +1,8 @@
|
||||
# F6.14 · Apertura móvil por Yacimiento
|
||||
|
||||
La APK abre nuevas Inspecciones únicamente después de seleccionar el contexto completo:
|
||||
Departamento → Área → Yacimiento → Operadora.
|
||||
|
||||
El Yacimiento seleccionado se persiste como `scopeAssetId` de la Inspección. La API valida toda la cadena territorial y que la Operadora corresponda al Yacimiento antes de crear e iniciar la Inspección.
|
||||
|
||||
Una Inspección en curso entra directamente al espacio de Actas. Los Hallazgos pueden registrarse únicamente sobre el Yacimiento de alcance, sus Instalaciones o sus Subinstalaciones. Ningún elemento fuera de esa rama puede incorporarse al trabajo de campo.
|
||||
@@ -0,0 +1,23 @@
|
||||
# F6.15 · Contexto documental y mapa operativo
|
||||
|
||||
## Actas
|
||||
|
||||
- El detalle de Acta expone Departamento, Área, Yacimiento y Empresa/Operadora.
|
||||
- Las Instalaciones y Subinstalaciones mostradas son únicamente las que tienen Hallazgos en esa Acta.
|
||||
- Cada Hallazgo explica el elemento afectado y su ruta territorial/técnica completa.
|
||||
- Las Inspecciones históricas cuyo alcance quedó guardado como Área se identifican como tales; el sistema no inventa un Yacimiento.
|
||||
|
||||
## Informe relacionado
|
||||
|
||||
- El estado visible del Informe se obtiene del workflow documental real.
|
||||
- `OFFICIALIZED` se presenta como oficializado en GEDO y muestra el identificador IF cuando existe.
|
||||
- `wordStatus=READY` indica que el INF está listo para remitir a GEDO; `pdfStatus` ya no determina si el Informe está "en preparación".
|
||||
|
||||
## Mapa
|
||||
|
||||
- Capas independientes: Inventario GPS, Yacimientos, Empresas, Actas y Hallazgos.
|
||||
- Incluye buscador transversal y conserva filtros de Inventario por tipo, estado y geometría.
|
||||
- Un Yacimiento sin geometría propia sólo se ubica cuando alguna Instalación/Subinstalación descendiente tiene geometría real; se utiliza el centroide de las geometrías registradas.
|
||||
- La Empresa se representa como presencia operativa en el Yacimiento que opera, no como una sede inventada.
|
||||
- Hallazgos usan la geometría exacta del elemento afectado cuando existe y, como respaldo, la ubicación derivada del Yacimiento.
|
||||
- Actas usan la ubicación derivada de su Yacimiento. Los registros sin referencia geográfica real no se dibujan.
|
||||
@@ -4,6 +4,7 @@ COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY tsconfig*.json vite.config.ts index.html ./
|
||||
COPY public ./public
|
||||
COPY scripts ./scripts
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location = /assets/maplibre-gl-worker.mjs {
|
||||
default_type application/javascript;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "dhv2-web",
|
||||
"version": "0.23.0-8",
|
||||
"version": "0.23.0-11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dhv2-web",
|
||||
"version": "0.23.0-8",
|
||||
"version": "0.23.0-11",
|
||||
"dependencies": {
|
||||
"maplibre-gl": "6.4.1",
|
||||
"react": "^19.0.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-web",
|
||||
"version": "0.23.0-8",
|
||||
"version": "0.23.0-11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "tsc -b && vite build && node scripts/copy-maplibre-worker.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { chmodSync, copyFileSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const source = resolve('node_modules/maplibre-gl/dist/maplibre-gl-worker.mjs');
|
||||
const target = resolve('dist/assets/maplibre-gl-worker.mjs');
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
copyFileSync(source, target);
|
||||
chmodSync(target, 0o644);
|
||||
const bytes = statSync(target).size;
|
||||
if (bytes < 1000) throw new Error(`MapLibre worker inválido: ${bytes} bytes`);
|
||||
console.log(`MapLibre worker: ${target} (${bytes} bytes)`);
|
||||
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.23.0-8';
|
||||
export const APP_PHASE = 'F6.11 · GEDO y respuestas de informes';
|
||||
export const APP_VERSION = '0.23.0-11';
|
||||
export const APP_PHASE = 'F6.15 · Actas y mapa operativo';
|
||||
|
||||
@@ -30,9 +30,18 @@ function Photo({ id, title, caption, load }: { id: string; title: string; captio
|
||||
|
||||
function Finding({ item }: { item: FindingWithPhotos }) {
|
||||
const { finding, photos } = item;
|
||||
const hierarchy = finding.asset.hierarchy;
|
||||
return <article className="act-finding-record">
|
||||
<div className="inspection-finding-heading"><div><span className="eyebrow">{finding.code}</span><h3>{finding.title}</h3><p>{finding.asset.name} · {finding.asset.code}</p></div></div>
|
||||
<p className="inspection-finding-description">{finding.description}</p>
|
||||
<div className="inspection-finding-heading"><div><span className="eyebrow">{finding.code}</span><h3>{finding.title}</h3><p><strong>Elemento afectado:</strong> {finding.asset.typeName} · {finding.asset.name} · {finding.asset.code}</p></div></div>
|
||||
{hierarchy && <div className="responsible-summary act-finding-context">
|
||||
<div><small>Departamento</small><strong>{hierarchy.department?.name ?? '—'}</strong></div>
|
||||
<div><small>Área</small><strong>{hierarchy.area?.name ?? '—'}</strong></div>
|
||||
<div><small>Yacimiento</small><strong>{hierarchy.yacimiento?.name ?? '—'}</strong></div>
|
||||
<div><small>Empresa</small><strong>{hierarchy.company?.name ?? '—'}</strong></div>
|
||||
<div><small>Instalación</small><strong>{hierarchy.installation?.name ?? 'No corresponde'}</strong></div>
|
||||
<div><small>Subinstalación</small><strong>{hierarchy.subinstallation?.name ?? 'No corresponde'}</strong></div>
|
||||
</div>}
|
||||
<p className="inspection-finding-description"><strong>Constatación:</strong> {finding.description}</p>
|
||||
{finding.legalBasis && <p><strong>Normativa:</strong> {finding.legalBasis}</p>}
|
||||
{finding.severity != null && <p>Gravedad {finding.severity}/10</p>}
|
||||
{photos.length > 0 && <div className="act-finding-photos">
|
||||
|
||||
@@ -41,22 +41,22 @@ function localDateTime(value: string | number | Date): string {
|
||||
|
||||
function geometryVertices(geometry: GeoJsonGeometry | null): Position[] {
|
||||
if (!geometry) return [];
|
||||
if (geometry.type === 'POINT') return [geometry.coordinates];
|
||||
if (geometry.type === 'LINESTRING') return geometry.coordinates;
|
||||
if (geometry.type === 'Point') return [geometry.coordinates];
|
||||
if (geometry.type === 'LineString') return geometry.coordinates;
|
||||
return geometry.coordinates[0]?.slice(0, -1) ?? [];
|
||||
}
|
||||
|
||||
function draftGeometry(type: AssetGeometryType, vertices: Position[]): GeoJsonGeometry | null {
|
||||
if (type === 'POINT') return vertices[0] ? { type, coordinates: vertices[0] } : null;
|
||||
if (type === 'LINESTRING') return vertices.length >= 2 ? { type, coordinates: vertices } : null;
|
||||
if (type === 'POINT') return vertices[0] ? { type: 'Point', coordinates: vertices[0] } : null;
|
||||
if (type === 'LINESTRING') return vertices.length >= 2 ? { type: 'LineString', coordinates: vertices } : null;
|
||||
if (vertices.length < 3) return null;
|
||||
return { type, coordinates: [[...vertices, vertices[0]!]] };
|
||||
return { type: 'Polygon', coordinates: [[...vertices, vertices[0]!]] };
|
||||
}
|
||||
|
||||
function drawingCollection(geometry: GeoJsonGeometry | null, vertices: Position[]) {
|
||||
const features: unknown[] = [];
|
||||
if (geometry) features.push({ type: 'Feature', properties: { kind: 'shape' }, geometry });
|
||||
if (geometry?.type !== 'POINT') {
|
||||
if (geometry?.type !== 'Point') {
|
||||
vertices.forEach((coordinates, index) => features.push({
|
||||
type: 'Feature', properties: { kind: 'vertex', index: index + 1 },
|
||||
geometry: { type: 'Point', coordinates },
|
||||
@@ -187,7 +187,7 @@ export function AssetGeometryEditor({
|
||||
const applyStored = (value: AssetGeometry | null) => {
|
||||
setStored(value);
|
||||
if (value) {
|
||||
setType(value.geometry.type);
|
||||
setType(value.geometryType);
|
||||
setVertices(geometryVertices(value.geometry));
|
||||
setAccuracyM(value.accuracyM == null ? '' : String(value.accuracyM));
|
||||
setCapturedAt(value.capturedAt ? localDateTime(value.capturedAt) : '');
|
||||
|
||||
@@ -20,14 +20,14 @@ const osmStyle = {
|
||||
layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }],
|
||||
};
|
||||
|
||||
const interactiveLayers = ['assets-points', 'assets-lines', 'assets-polygons'];
|
||||
const interactiveLayers = ['assets-points', 'yacimiento-points', 'company-points', 'act-points', 'finding-points', 'assets-lines', 'assets-polygons'];
|
||||
|
||||
function boundsFromFeatures(collection: MapAssetFeatureCollection) {
|
||||
const positions: Array<[number, number]> = [];
|
||||
collection.features.forEach((feature) => {
|
||||
if (feature.geometry.type === 'POINT') positions.push(feature.geometry.coordinates);
|
||||
if (feature.geometry.type === 'LINESTRING') positions.push(...feature.geometry.coordinates);
|
||||
if (feature.geometry.type === 'POLYGON') feature.geometry.coordinates.forEach((ring) => positions.push(...ring));
|
||||
if (feature.geometry.type === 'Point') positions.push(feature.geometry.coordinates);
|
||||
if (feature.geometry.type === 'LineString') positions.push(...feature.geometry.coordinates);
|
||||
if (feature.geometry.type === 'Polygon') feature.geometry.coordinates.forEach((ring) => positions.push(...ring));
|
||||
});
|
||||
if (!positions.length) return null;
|
||||
return positions.reduce<[number, number, number, number]>((result, point) => [
|
||||
@@ -79,11 +79,28 @@ export function DhMap({
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'assets-points', type: 'circle', source: 'assets',
|
||||
filter: ['==', ['geometry-type'], 'Point'],
|
||||
paint: {
|
||||
'circle-radius': 7, 'circle-color': '#2864dc',
|
||||
'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2,
|
||||
},
|
||||
filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'entityKind'], 'ASSET']],
|
||||
paint: { 'circle-radius': 7, 'circle-color': '#64748b', 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'yacimiento-points', type: 'circle', source: 'assets',
|
||||
filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'entityKind'], 'YACIMIENTO']],
|
||||
paint: { 'circle-radius': 9, 'circle-color': '#2563eb', 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'company-points', type: 'circle', source: 'assets',
|
||||
filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'entityKind'], 'COMPANY']],
|
||||
paint: { 'circle-radius': 8, 'circle-color': '#059669', 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'act-points', type: 'circle', source: 'assets',
|
||||
filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'entityKind'], 'ACT']],
|
||||
paint: { 'circle-radius': 9, 'circle-color': '#7c3aed', 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'finding-points', type: 'circle', source: 'assets',
|
||||
filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'entityKind'], 'FINDING']],
|
||||
paint: { 'circle-radius': 8, 'circle-color': '#dc2626', 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'assets-selected-polygons', type: 'line', source: 'assets',
|
||||
|
||||
+52
-12
@@ -536,9 +536,9 @@ export type Position = [number, number];
|
||||
export type AssetGeometryType = 'POINT' | 'LINESTRING' | 'POLYGON';
|
||||
|
||||
export type GeoJsonGeometry =
|
||||
| { type: 'POINT'; coordinates: Position }
|
||||
| { type: 'LINESTRING'; coordinates: Position[] }
|
||||
| { type: 'POLYGON'; coordinates: Position[][] };
|
||||
| { type: 'Point'; coordinates: Position }
|
||||
| { type: 'LineString'; coordinates: Position[] }
|
||||
| { type: 'Polygon'; coordinates: Position[][] };
|
||||
|
||||
export interface AssetGeometry {
|
||||
assetId: string;
|
||||
@@ -553,21 +553,34 @@ export interface AssetGeometry {
|
||||
updatedBy: string | null;
|
||||
}
|
||||
|
||||
export type MapEntityKind = 'ASSET' | 'YACIMIENTO' | 'COMPANY' | 'ACT' | 'FINDING';
|
||||
|
||||
export interface MapAssetProperties {
|
||||
id: string;
|
||||
entityId?: string;
|
||||
entityKind?: MapEntityKind;
|
||||
code: string;
|
||||
name: string;
|
||||
commonName?: string | null;
|
||||
typeId: string;
|
||||
typeCode: string;
|
||||
typeId?: string | null;
|
||||
typeCode?: string | null;
|
||||
typeName: string;
|
||||
parentId: string | null;
|
||||
parentName: string | null;
|
||||
informationStatus: AssetInformationStatus;
|
||||
parentId?: string | null;
|
||||
parentName?: string | null;
|
||||
informationStatus?: AssetInformationStatus | null;
|
||||
geometryType: AssetGeometryType;
|
||||
accuracyM: number | null;
|
||||
capturedAt: string | null;
|
||||
accuracyM?: number | null;
|
||||
capturedAt?: string | null;
|
||||
updatedAt: string;
|
||||
href?: string | null;
|
||||
contextLine?: string | null;
|
||||
departmentName?: string | null;
|
||||
areaName?: string | null;
|
||||
yacimientoName?: string | null;
|
||||
companyName?: string | null;
|
||||
actCode?: string | null;
|
||||
assetName?: string | null;
|
||||
sourceGeometries?: number | null;
|
||||
}
|
||||
|
||||
export interface MapAssetFeature {
|
||||
@@ -1473,7 +1486,17 @@ export interface InspectionFinding {
|
||||
currentVersion: number;
|
||||
closedAt: string | null;
|
||||
closureNotes: string | null;
|
||||
asset: InspectionAssetSummary;
|
||||
asset: InspectionAssetSummary & {
|
||||
typeCode?: string;
|
||||
hierarchy?: {
|
||||
department: { id: string; code: string; name: string } | null;
|
||||
area: { id: string; code: string; name: string } | null;
|
||||
yacimiento: { id: string; code: string; name: string } | null;
|
||||
installation: { id: string; code: string; name: string } | null;
|
||||
subinstallation: { id: string; code: string; name: string } | null;
|
||||
company: { id: string; code: string; name: string } | null;
|
||||
};
|
||||
};
|
||||
catalog: {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -2162,6 +2185,15 @@ export async function getAssetGeometry(assetId: string) {
|
||||
return (await apiRequest<{ data: AssetGeometry | null }>(`/assets/${assetId}/geometry`)).data;
|
||||
}
|
||||
|
||||
function geometryPayload(geometry: GeoJsonGeometry) {
|
||||
const type: AssetGeometryType = geometry.type === 'Point'
|
||||
? 'POINT'
|
||||
: geometry.type === 'LineString'
|
||||
? 'LINESTRING'
|
||||
: 'POLYGON';
|
||||
return { type, coordinates: geometry.coordinates };
|
||||
}
|
||||
|
||||
export function upsertAssetGeometry(assetId: string, input: {
|
||||
geometry: GeoJsonGeometry;
|
||||
accuracyM?: number | null;
|
||||
@@ -2169,7 +2201,7 @@ export function upsertAssetGeometry(assetId: string, input: {
|
||||
deviceLabel?: string | null;
|
||||
}) {
|
||||
return apiRequest<AssetGeometry>(`/assets/${assetId}/geometry`, {
|
||||
method: 'PUT', body: JSON.stringify(input),
|
||||
method: 'PUT', body: JSON.stringify({ ...input, geometry: geometryPayload(input.geometry) }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2179,6 +2211,14 @@ export function removeAssetGeometry(assetId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function getMapOperationalContext() {
|
||||
return apiRequest<MapAssetFeatureCollection>('/map/context');
|
||||
}
|
||||
|
||||
export function getMapDocuments() {
|
||||
return apiRequest<MapAssetFeatureCollection>('/map/documents');
|
||||
}
|
||||
|
||||
export function getMapAssets(params: {
|
||||
bbox?: string;
|
||||
typeId?: string;
|
||||
|
||||
@@ -73,11 +73,23 @@ export interface InspectionActListItemF4 {
|
||||
findingCount: number;
|
||||
companies: Array<{ id: string; code: string; name: string }>;
|
||||
areas: Array<{ id: string; code: string; name: string }>;
|
||||
context: {
|
||||
department: { id: string; code: string; name: string } | null;
|
||||
area: { id: string; code: string; name: string } | null;
|
||||
yacimiento: { id: string; code: string; name: string } | null;
|
||||
company: { id: string; code: string; name: string } | null;
|
||||
installations: Array<{ id: string; code: string; name: string }>;
|
||||
subinstallations: Array<{ id: string; code: string; name: string }>;
|
||||
legacyAreaScope: boolean;
|
||||
};
|
||||
report: null | {
|
||||
id: string;
|
||||
code: string;
|
||||
status: InspectionReportStatusF4;
|
||||
pdfStatus: InspectionReportPdfStatus;
|
||||
wordStatus: 'PENDING' | 'READY' | 'FAILED';
|
||||
gedoIfIdentifier: string | null;
|
||||
gedoOfficializedAt: string | null;
|
||||
generatedAt: string;
|
||||
};
|
||||
createdBy: InspectionPerson | null;
|
||||
|
||||
@@ -111,13 +111,13 @@ export function ActsPage() {
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label="Cargando actas…" /> : items.length === 0 ? <EmptyState title="Sin actas" text="No hay actas para los filtros seleccionados." /> : <div className="table-panel document-table">
|
||||
<div className="table-summary"><strong>{meta.total} acta{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div>
|
||||
<div className="table-scroll"><table><thead><tr><th>Acta</th><th>Empresa / área</th><th>Inspección</th><th>Hallazgos</th><th>Estado</th><th>Informe</th><th /></tr></thead><tbody>{items.map((act) => <tr key={act.id}>
|
||||
<div className="table-scroll"><table><thead><tr><th>Acta</th><th>Empresa / territorio</th><th>Inspección</th><th>Hallazgos</th><th>Estado</th><th>Informe</th><th /></tr></thead><tbody>{items.map((act) => <tr key={act.id}>
|
||||
<td><div className="document-primary"><strong>{act.code}</strong><small>{formatDate(act.occurredAt)}</small></div></td>
|
||||
<td><div className="document-primary"><strong>{contextLabel(act.companies, 'Empresa sin asignar')}</strong><small>{contextLabel(act.areas, 'Área sin asignar')}</small></div></td>
|
||||
<td><div className="document-primary"><strong>{act.context.company?.name ?? contextLabel(act.companies, 'Empresa sin asignar')}</strong><small>{[act.context.department?.name, act.context.area?.name, act.context.yacimiento?.name].filter(Boolean).join(' · ') || contextLabel(act.areas, 'Área sin asignar')}</small></div></td>
|
||||
<td><Link className="text-link" to={`/inspecciones/${act.visitId}`}>{act.visit.code}</Link></td>
|
||||
<td><Link className="text-link" to={`/hallazgos?search=${encodeURIComponent(act.code)}`}>{act.findingCount}</Link></td>
|
||||
<td><span className={`status-badge ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span></td>
|
||||
<td>{act.report ? <Link className="text-link" to={`/informes/${act.report.id}`}>{act.report.code}<small className="block-muted">{act.report.pdfStatus === 'READY' ? 'PDF disponible' : 'PDF pendiente'}</small></Link> : ['SEALED', 'CLOSED'].includes(act.status) ? <span className="status-badge pending">Pendiente de emisión</span> : <span className="muted">—</span>}</td>
|
||||
<td>{act.report ? <Link className="text-link" to={`/informes/${act.report.id}`}>{act.report.code}<small className="block-muted">{act.report.status === 'OFFICIALIZED' ? `Oficializado en GEDO${act.report.gedoIfIdentifier ? ` · ${act.report.gedoIfIdentifier}` : ''}` : act.report.wordStatus === 'READY' ? 'INF listo' : 'En preparación'}</small></Link> : ['SEALED', 'CLOSED'].includes(act.status) ? <span className="status-badge pending">Pendiente de emisión</span> : <span className="muted">—</span>}</td>
|
||||
<td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><Icon name="chevron" /></Link></td>
|
||||
</tr>)}</tbody></table></div>
|
||||
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
|
||||
|
||||
@@ -80,10 +80,23 @@ export function InspectionActEditorPage() {
|
||||
<div className="act-document-primary-copy"><span className="asset-symbol"><Icon name="clipboard" /></span><div><span className="eyebrow">DOCUMENTO DEL ACTA</span><h2>{isSealed ? 'Acta consolidada disponible' : 'Acta en preparación'}</h2><p>{isSealed ? 'Abrí o descargá el Acta firmada, con sus Hallazgos y constancias de integridad.' : 'El documento definitivo se genera al firmar y cerrar el Acta.'}</p></div></div>
|
||||
<div className="act-primary-actions">{isSealed ? <><button className="button primary" type="button" disabled={pdfBusy} onClick={() => void openActPdf(false)}>{pdfBusy ? 'Preparando…' : 'Abrir PDF del Acta'}</button><button className="button secondary" type="button" disabled={pdfBusy} onClick={() => void openActPdf(true)}>Descargar PDF</button></> : <span className="status-badge pending">{inspectionActStatusLabel(act.status)}</span>}</div>
|
||||
</section>}
|
||||
{act?.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Informe relacionado: <Link to={`/informes/${act.report.id}`}>{act.report.code}</Link>.</strong> {act.report.pdfStatus === 'READY' ? ' Disponible.' : ' En preparación.'}</p></div>}
|
||||
{act?.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Informe relacionado: <Link to={`/informes/${act.report.id}`}>{act.report.code}</Link>.</strong> {act.report.status === 'OFFICIALIZED' ? ` Oficializado en GEDO${act.report.gedoIfIdentifier ? ` · ${act.report.gedoIfIdentifier}` : ''}.` : act.report.status === 'FROZEN' ? ' Informe consolidado.' : act.report.wordStatus === 'READY' ? ' INF listo para enviar a GEDO.' : ' En preparación.'}</p></div>}
|
||||
{isSealed && act && !act.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>INF pendiente de emisión.</strong> El Acta ya está sellada y disponible como documento fuente.</p></div>}
|
||||
{act?.status === 'CANCELLED' && <Alert>Cancelada: {act.cancellationReason}</Alert>}
|
||||
|
||||
{act && <section className="panel inspection-act-context">
|
||||
<div className="panel-heading"><div><span className="eyebrow">CONTEXTO DEL ACTA</span><h2>Ubicación territorial y operativa</h2><p className="section-copy">El Acta corresponde a un Yacimiento y los Hallazgos se ubican dentro de sus Instalaciones y Subinstalaciones.</p></div></div>
|
||||
<div className="responsible-summary">
|
||||
<div><small>Departamento</small><strong>{act.context.department?.name ?? 'Sin definir'}</strong>{act.context.department && <span>{act.context.department.code}</span>}</div>
|
||||
<div><small>Área</small><strong>{act.context.area?.name ?? 'Sin definir'}</strong>{act.context.area && <span>{act.context.area.code}</span>}</div>
|
||||
<div><small>Yacimiento</small><strong>{act.context.yacimiento?.name ?? 'No definido en la Inspección histórica'}</strong>{act.context.yacimiento && <span>{act.context.yacimiento.code}</span>}</div>
|
||||
<div><small>Empresa / Operadora</small><strong>{act.context.company?.name ?? 'Sin definir'}</strong>{act.context.company && <span>{act.context.company.code}</span>}</div>
|
||||
<div><small>Instalaciones con Hallazgos</small><strong>{act.context.installations.length ? act.context.installations.map((item) => item.name).join(' · ') : 'Ninguna'}</strong></div>
|
||||
<div><small>Subinstalaciones con Hallazgos</small><strong>{act.context.subinstallations.length ? act.context.subinstallations.map((item) => item.name).join(' · ') : 'Ninguna'}</strong></div>
|
||||
</div>
|
||||
{act.context.legacyAreaScope && !act.context.yacimiento && <Alert type="info">Esta Acta pertenece a una Inspección histórica creada antes de exigir Yacimiento como alcance. El Área se conserva como fue registrada; no se la presenta como Yacimiento.</Alert>}
|
||||
</section>}
|
||||
|
||||
{act && <section className="panel inspection-act-form">
|
||||
<div className="responsible-summary">
|
||||
<div><small>Fecha de inspección</small><strong>{formatDate(act.occurredAt)}</strong></div>
|
||||
|
||||
@@ -1,74 +1,96 @@
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { AssetCenterTabs } from '../features/assets/AssetCenterTabs';
|
||||
import { DhMap } from '../features/map/DhMap';
|
||||
import {
|
||||
assetStatusClass,
|
||||
assetStatusLabel,
|
||||
ASSET_STATUSES,
|
||||
} from '../features/assets/assetPresentation';
|
||||
import { getMapAssets, listAssetTypes } from '../lib/api';
|
||||
import type {
|
||||
AssetGeometryType,
|
||||
AssetInformationStatus,
|
||||
AssetType,
|
||||
MapAssetFeatureCollection,
|
||||
} from '../lib/api';
|
||||
import { assetStatusClass, assetStatusLabel, ASSET_STATUSES } from '../features/assets/assetPresentation';
|
||||
import { getMapAssets, getMapDocuments, getMapOperationalContext, listAssetTypes } from '../lib/api';
|
||||
import type { AssetGeometryType, AssetInformationStatus, AssetType, MapAssetFeatureCollection, MapEntityKind } from '../lib/api';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
const emptyCollection: MapAssetFeatureCollection = {
|
||||
type: 'FeatureCollection', features: [], meta: { count: 0, truncated: false },
|
||||
const emptyCollection: MapAssetFeatureCollection = { type: 'FeatureCollection', features: [], meta: { count: 0, truncated: false } };
|
||||
const layerLabels: Record<MapEntityKind, string> = {
|
||||
ASSET: 'Inventario GPS', YACIMIENTO: 'Yacimientos', COMPANY: 'Empresas', ACT: 'Actas', FINDING: 'Hallazgos',
|
||||
};
|
||||
|
||||
export function MapPage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canReadDocuments = hasPermission('inspection_acts.read') && hasPermission('inspection_findings.read');
|
||||
const [types, setTypes] = useState<AssetType[]>([]);
|
||||
const [data, setData] = useState<MapAssetFeatureCollection>(emptyCollection);
|
||||
const [assets, setAssets] = useState<MapAssetFeatureCollection>(emptyCollection);
|
||||
const [context, setContext] = useState<MapAssetFeatureCollection>(emptyCollection);
|
||||
const [documents, setDocuments] = useState<MapAssetFeatureCollection>(emptyCollection);
|
||||
const [typeId, setTypeId] = useState('');
|
||||
const [status, setStatus] = useState<AssetInformationStatus | ''>('');
|
||||
const [geometryType, setGeometryType] = useState<AssetGeometryType | ''>('');
|
||||
const [mapSearch, setMapSearch] = useState('');
|
||||
const [layers, setLayers] = useState<Record<MapEntityKind, boolean>>({ ASSET: true, YACIMIENTO: true, COMPANY: true, ACT: true, FINDING: true });
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
listAssetTypes().then(setTypes).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { listAssetTypes().then(setTypes).catch(() => undefined); }, []);
|
||||
useEffect(() => {
|
||||
setLoading(true); setError('');
|
||||
getMapAssets({ typeId, status, geometryType })
|
||||
.then((result) => {
|
||||
setData(result);
|
||||
setSelectedId((current) => result.features.some((item) => item.id === current) ? current : null);
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [typeId, status, geometryType]);
|
||||
Promise.all([
|
||||
getMapAssets({ typeId, status, geometryType }),
|
||||
getMapOperationalContext(),
|
||||
canReadDocuments ? getMapDocuments() : Promise.resolve(emptyCollection),
|
||||
]).then(([assetResult, contextResult, documentResult]) => {
|
||||
setAssets(assetResult); setContext(contextResult); setDocuments(documentResult);
|
||||
}).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
||||
}, [typeId, status, geometryType, canReadDocuments]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => data.features.find((feature) => feature.id === selectedId) ?? null,
|
||||
[data, selectedId],
|
||||
);
|
||||
const data = useMemo<MapAssetFeatureCollection>(() => {
|
||||
const directAssets = assets.features.filter((feature) => !['yacimiento', 'empresa'].includes(feature.properties.typeCode?.toLowerCase() ?? ''));
|
||||
const all = [...directAssets, ...context.features, ...documents.features];
|
||||
const term = mapSearch.trim().toLocaleLowerCase('es-AR');
|
||||
const features = all.filter((feature) => {
|
||||
if (!layers[feature.properties.entityKind ?? 'ASSET']) return false;
|
||||
if (!term) return true;
|
||||
const searchable = [
|
||||
feature.properties.code, feature.properties.name, feature.properties.typeName,
|
||||
feature.properties.contextLine, feature.properties.departmentName, feature.properties.areaName,
|
||||
feature.properties.yacimientoName, feature.properties.companyName, feature.properties.actCode,
|
||||
feature.properties.assetName,
|
||||
].filter(Boolean).join(' ').toLocaleLowerCase('es-AR');
|
||||
return searchable.includes(term);
|
||||
});
|
||||
return { type: 'FeatureCollection', features, meta: { count: features.length, truncated: assets.meta.truncated } };
|
||||
}, [assets, context, documents, layers, mapSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedId((current) => data.features.some((item) => item.id === current) ? current : null);
|
||||
}, [data]);
|
||||
|
||||
const selected = useMemo(() => data.features.find((feature) => feature.id === selectedId) ?? null, [data, selectedId]);
|
||||
const toggleLayer = (kind: MapEntityKind) => setLayers((current) => ({ ...current, [kind]: !current[kind] }));
|
||||
const kind = selected?.properties.entityKind ?? 'ASSET';
|
||||
|
||||
return <section>
|
||||
<div className="page-heading"><div><span className="eyebrow">INVENTARIOS</span><h1>Mapa de inventarios</h1><p>Vista territorial de las ubicaciones registradas.</p></div><span className="map-count"><strong>{data.meta.count}</strong> geometría{data.meta.count === 1 ? '' : 's'}</span></div>
|
||||
<div className="page-heading"><div><span className="eyebrow">TERRITORIO Y OPERACIÓN</span><h1>Mapa operativo</h1><p>Yacimientos, presencia de Empresas, Actas, Hallazgos e Inventario con ubicación real o derivada de geometrías registradas.</p></div><span className="map-count"><strong>{data.meta.count}</strong> elemento{data.meta.count === 1 ? '' : 's'}</span></div>
|
||||
<AssetCenterTabs active="map" />
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{data.meta.truncated && <Alert type="info">Se muestran los primeros 5000 registros. Aplicá filtros para reducir el resultado.</Alert>}
|
||||
{data.meta.truncated && <Alert type="info">Se muestran los primeros 5000 registros de Inventario. Aplicá filtros para reducir el resultado.</Alert>}
|
||||
<div className="map-layout operational-map-layout">
|
||||
<aside className="filters map-sidebar">
|
||||
<div><span className="eyebrow">FILTROS</span><h2>Vista territorial</h2></div>
|
||||
<label className="field"><span>Tipo de elemento</span><SearchableSelect value={typeId} onChange={(event) => setTypeId(event.target.value)}><option value="">Todos</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
|
||||
<label className="field"><span>Estado de información</span><SearchableSelect value={status} onChange={(event) => setStatus(event.target.value as AssetInformationStatus | '')}><option value="">Todos</option>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
|
||||
<label className="field"><span>Geometría</span><SearchableSelect value={geometryType} onChange={(event) => setGeometryType(event.target.value as AssetGeometryType | '')}><option value="">Todas</option><option value="POINT">Puntos</option><option value="LINESTRING">Líneas</option><option value="POLYGON">Polígonos</option></SearchableSelect></label>
|
||||
<button className="button secondary wide" onClick={() => { setTypeId(''); setStatus(''); setGeometryType(''); }}>Limpiar filtros</button>
|
||||
<div><span className="eyebrow">CAPAS</span><h2>Qué mostrar</h2></div>
|
||||
<label className="search-field map-global-search"><Icon name="search" /><input value={mapSearch} onChange={(event) => setMapSearch(event.target.value)} placeholder="Buscar Yacimiento, Empresa, Acta, Hallazgo…" /></label>
|
||||
<div className="map-layer-buttons">
|
||||
{(Object.keys(layerLabels) as MapEntityKind[]).map((item) => item === 'ACT' || item === 'FINDING' ? (canReadDocuments && <button key={item} type="button" className={`button compact ${layers[item] ? 'primary' : 'secondary'}`} onClick={() => toggleLayer(item)}>{layerLabels[item]}</button>) : <button key={item} type="button" className={`button compact ${layers[item] ? 'primary' : 'secondary'}`} onClick={() => toggleLayer(item)}>{layerLabels[item]}</button>)}
|
||||
</div>
|
||||
<div><span className="eyebrow">FILTROS DE INVENTARIO</span></div>
|
||||
<label className="field"><span>Tipo de elemento</span><SearchableSelect value={typeId} onChange={(event) => setTypeId(event.target.value)} disabled={!layers.ASSET}><option value="">Todos</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
|
||||
<label className="field"><span>Estado de información</span><SearchableSelect value={status} onChange={(event) => setStatus(event.target.value as AssetInformationStatus | '')} disabled={!layers.ASSET}><option value="">Todos</option>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
|
||||
<label className="field"><span>Geometría</span><SearchableSelect value={geometryType} onChange={(event) => setGeometryType(event.target.value as AssetGeometryType | '')} disabled={!layers.ASSET}><option value="">Todas</option><option value="POINT">Puntos</option><option value="LINESTRING">Líneas</option><option value="POLYGON">Polígonos</option></SearchableSelect></label>
|
||||
<button className="button secondary wide" onClick={() => { setTypeId(''); setStatus(''); setGeometryType(''); }}>Limpiar filtros de Inventario</button>
|
||||
|
||||
{selected && <div className="map-selection"><span className="eyebrow">REGISTRO SELECCIONADO</span><h3>{selected.properties.name}</h3><code>{selected.properties.code}</code><div className="map-selection-meta"><span className="tag">{selected.properties.typeName}</span><span className={`status-badge ${assetStatusClass(selected.properties.informationStatus)}`}>{assetStatusLabel(selected.properties.informationStatus)}</span></div>{selected.properties.parentName && <p>Depende de <strong>{selected.properties.parentName}</strong></p>}<p>{selected.properties.geometryType === 'POINT' ? 'Punto' : selected.properties.geometryType === 'LINESTRING' ? 'Línea' : 'Polígono'} · actualizado {formatDate(selected.properties.updatedAt)}</p>{selected.properties.accuracyM != null && <p>Precisión informada: {selected.properties.accuracyM} m</p>}<Link className="button primary wide" to={`/inventarios/${selected.id}`}>Abrir registro <Icon name="chevron" /></Link></div>}
|
||||
{selected && <div className="map-selection"><span className="eyebrow">{layerLabels[kind]}</span><h3>{selected.properties.name}</h3><code>{selected.properties.code}</code><div className="map-selection-meta"><span className="tag">{selected.properties.typeName}</span>{kind === 'ASSET' && selected.properties.informationStatus && <span className={`status-badge ${assetStatusClass(selected.properties.informationStatus)}`}>{assetStatusLabel(selected.properties.informationStatus)}</span>}</div>{selected.properties.contextLine && <p>{selected.properties.contextLine}</p>}{selected.properties.departmentName && <p><strong>Departamento:</strong> {selected.properties.departmentName}</p>}{selected.properties.areaName && <p><strong>Área:</strong> {selected.properties.areaName}</p>}{selected.properties.yacimientoName && <p><strong>Yacimiento:</strong> {selected.properties.yacimientoName}</p>}{selected.properties.companyName && <p><strong>Empresa:</strong> {selected.properties.companyName}</p>}{selected.properties.assetName && <p><strong>Elemento:</strong> {selected.properties.assetName}</p>}<p>Ubicación actualizada {formatDate(selected.properties.updatedAt)}</p>{selected.properties.sourceGeometries != null && <p><small>Ubicación territorial derivada de {selected.properties.sourceGeometries} geometría{selected.properties.sourceGeometries === 1 ? '' : 's'} registrada{selected.properties.sourceGeometries === 1 ? '' : 's'} en el Yacimiento.</small></p>}{selected.properties.href && <Link className="button primary wide" to={selected.properties.href}>Abrir {layerLabels[kind].replace(/s$/, '')} <Icon name="chevron" /></Link>}</div>}
|
||||
</aside>
|
||||
<div className="map-stage">{loading && <div className="map-loading"><LoadingBlock label="Actualizando mapa…" /></div>}<DhMap data={data} selectedId={selectedId} onSelect={setSelectedId} />{!loading && data.features.length === 0 && <div className="map-empty"><Icon name="map" size={30} /><strong>No hay geometrías para mostrar</strong><span>Agregá una ubicación desde el detalle de un registro.</span></div>}</div>
|
||||
<div className="map-stage">{loading && <div className="map-loading"><LoadingBlock label="Actualizando mapa…" /></div>}<DhMap data={data} selectedId={selectedId} onSelect={setSelectedId} />{!loading && data.features.length === 0 && <div className="map-empty"><Icon name="map" size={30} /><strong>No hay ubicaciones para mostrar</strong><span>Las capas sólo muestran registros con una geometría real propia o derivable de su Yacimiento.</span></div>}</div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ export function ReportDetailPage() {
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">GEDO</span><h2>Oficialización del Informe</h2><p className="section-copy">No existe una respuesta automática de GEDO. Cuando recibas el identificador IF y el PDF oficial, cargalos manualmente aquí.</p></div></div>
|
||||
{report.status === 'OFFICIALIZED' ? <><div className="responsible-summary"><div><small>Identificador IF</small><strong>{report.gedoIfIdentifier ?? '—'}</strong></div><div><small>Fecha GEDO</small><strong>{formatDate(report.gedoOfficializedAt)}</strong></div><div><small>PDF oficial</small><strong>{report.gedoPdfOriginalName ?? 'Registrado'}</strong></div><div><small>Vencimiento de respuestas</small><strong>{formatDateOnly(report.responseDueOn)}</strong></div></div>{report.gedoPdfOriginalName && <div className="form-actions"><a className="button secondary" href={inspectionReportGedoPdfDownloadUrl(report.id)}>Descargar PDF oficial</a></div>}{report.gedoPdfSha256 && <div className="temporal-notice"><Icon name="check" /><p><strong>PDF GEDO fijado.</strong> SHA-256: <code>{report.gedoPdfSha256}</code></p></div>}</> : canManage && report.status === 'WORKING' ? <form className="form-section" onSubmit={officialize}><div className="form-grid"><label className="field"><span>Identificador IF de GEDO</span><input value={gedoIfIdentifier} onChange={(event) => setGedoIfIdentifier(event.target.value)} required maxLength={255} placeholder="IF-2026-…" /></label><label className="field"><span>Fecha de oficialización</span><input type="datetime-local" value={gedoOfficializedAt} onChange={(event) => setGedoOfficializedAt(event.target.value)} required /></label></div><label className="field"><span>PDF oficial de GEDO</span><input type="file" accept="application/pdf,.pdf" onChange={(event) => setGedoFile(event.target.files?.[0] ?? null)} required /></label><Alert>Esta carga es manual. Registra la referencia institucional del Informe, pero no crea respuestas ni vencimientos automáticamente.</Alert><div className="form-actions"><button className="button primary" disabled={working || !gedoFile || !gedoIfIdentifier.trim()}>{working ? 'Registrando…' : 'Cargar IF y PDF oficial'}</button></div></form> : <Alert type="info">El Informe todavía no está oficializado en GEDO.</Alert>}
|
||||
{report.status === 'OFFICIALIZED' ? <><div className="responsible-summary"><div><small>Identificador IF</small><strong>{report.gedoIfIdentifier ?? '—'}</strong></div><div><small>Fecha GEDO</small><strong>{formatDate(report.gedoOfficializedAt)}</strong></div><div><small>PDF oficial</small><strong>{report.gedoPdfOriginalName ?? 'Registrado'}</strong></div><div><small>Vencimiento de respuestas</small><strong>{formatDateOnly(report.responseDueOn)}</strong></div></div>{report.gedoPdfOriginalName && <div className="form-actions"><a className="button secondary" href={inspectionReportGedoPdfDownloadUrl(report.id)}>Descargar PDF oficial</a></div>}{report.gedoPdfSha256 && <div className="temporal-notice"><Icon name="check" /><p><strong>PDF GEDO fijado.</strong> SHA-256: <code>{report.gedoPdfSha256}</code></p></div>}</> : canManage && report.status === 'WORKING' ? <form className="form-section" onSubmit={officialize}><div className="form-grid"><label className="field"><span>Identificador IF de GEDO</span><input value={gedoIfIdentifier} onChange={(event) => setGedoIfIdentifier(event.target.value)} required maxLength={255} placeholder="IF-2026-…" /></label><label className="field"><span>Fecha de oficialización</span><input type="datetime-local" value={gedoOfficializedAt} onChange={(event) => setGedoOfficializedAt(event.target.value)} required /></label></div><label className="field"><span>PDF oficial de GEDO</span><input type="file" accept="application/pdf,.pdf" onChange={(event) => setGedoFile(event.target.files?.[0] ?? null)} required /></label><Alert>Esta carga es manual. Registra la referencia institucional del Informe, pero no crea respuestas ni vencimientos automáticamente.</Alert><div className="form-actions"><button className="button primary" disabled={working || !gedoFile || !gedoIfIdentifier.trim()}>{working ? 'Registrando…' : 'Cargar IF y PDF oficial'}</button></div></form> : <Alert type="info">El Informe todavía no está oficializado en GEDO.{report.status === 'WORKING' && !canManage ? ' Tu usuario no tiene permiso para gestionar u oficializar Informes.' : ''}</Alert>}
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
|
||||
@@ -1436,3 +1436,9 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; }
|
||||
|
||||
.act-other-asset-photos { border-top: 1px solid #e3eaf5; margin-top: 24px; padding-top: 20px; }
|
||||
.act-other-asset-photos h3 { margin: 0 0 14px; }
|
||||
|
||||
/* F6.15 · capas del mapa operativo */
|
||||
.map-layer-buttons { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.map-layer-buttons .button { flex: 1 1 calc(50% - 6px); justify-content: center; min-width: 104px; }
|
||||
.map-global-search { width: 100%; margin: 0; }
|
||||
.act-finding-context { margin: 10px 0 12px; }
|
||||
|
||||
Reference in New Issue
Block a user