Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1beba14c3b | ||
|
|
fa6ba7f31e | ||
|
|
a414d0ed36 | ||
|
|
0b731b722f | ||
|
|
678b0f1792 | ||
|
|
c71afab9af | ||
|
|
29208f4e1f | ||
|
|
4523aef932 | ||
|
|
011b3e7fcb | ||
|
|
9b7d67bea2 | ||
|
|
f0b92c00e1 | ||
|
|
cab9cd7c01 | ||
|
|
c9575cc520 | ||
|
|
69aeb52040 | ||
|
|
8d5ffc86f9 | ||
|
|
8266bd8669 | ||
|
|
510a5fbca3 | ||
|
|
215f443f71 | ||
|
|
ff297c8d93 | ||
|
|
c78c2b8029 | ||
|
|
60ba37c287 | ||
|
|
669c0d2656 | ||
|
|
5cf99442a8 |
@@ -1,5 +1,5 @@
|
|||||||
name: Android CI / RC
|
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:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -111,7 +111,7 @@ jobs:
|
|||||||
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
|
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
|
||||||
sha256sum "$apk" > "${apk}.sha256"
|
sha256sum "$apk" > "${apk}.sha256"
|
||||||
{
|
{
|
||||||
echo "phase=F6.8"
|
echo "phase=F6.14"
|
||||||
echo "version=$version"
|
echo "version=$version"
|
||||||
echo "versionCode=$code"
|
echo "versionCode=$code"
|
||||||
echo "commit=$GITHUB_SHA"
|
echo "commit=$GITHUB_SHA"
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches:
|
||||||
|
- main
|
||||||
|
- 'feature/**'
|
||||||
|
- 'fix/**'
|
||||||
|
- 'chore/**'
|
||||||
|
- 'release/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -52,9 +58,10 @@ jobs:
|
|||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|
||||||
contract:
|
contract:
|
||||||
name: Docker / scripts contract
|
name: Docker / migrations / production images
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [api, web]
|
needs: [api, web]
|
||||||
|
if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Validate shell scripts
|
- name: Validate shell scripts
|
||||||
@@ -347,3 +354,22 @@ jobs:
|
|||||||
docker image rm "$image" >/dev/null 2>&1 || true
|
docker image rm "$image" >/dev/null 2>&1 || true
|
||||||
- name: Build production images
|
- name: Build production images
|
||||||
run: docker compose --env-file .env.example build api migrate web
|
run: docker compose --env-file .env.example build api migrate web
|
||||||
|
|
||||||
|
promote-deploy:
|
||||||
|
name: Promote verified main to deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [api, web, contract]
|
||||||
|
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Fast-forward deploy to the verified commit
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
git fetch origin deploy main
|
||||||
|
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
|
||||||
|
git merge-base --is-ancestor origin/deploy "$GITHUB_SHA"
|
||||||
|
git push origin "$GITHUB_SHA:refs/heads/deploy"
|
||||||
|
|||||||
+18
-1
@@ -1,7 +1,17 @@
|
|||||||
# DH Inspección Android · release final de campo 0.19.10
|
# DH Inspección Android · release de campo 0.19.13
|
||||||
|
|
||||||
## Candidata vigente
|
## Candidata vigente
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
## Referencia de 0.19.10
|
||||||
|
|
||||||
|
### Candidata anterior
|
||||||
|
|
||||||
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
|
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
|
||||||
- `versionName`: **0.19.10**.
|
- `versionName`: **0.19.10**.
|
||||||
- `versionCode`: **38**.
|
- `versionCode`: **38**.
|
||||||
@@ -151,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.
|
- 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.
|
- 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í.
|
- `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.
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ plugins {
|
|||||||
android {
|
android {
|
||||||
namespace = "com.korexlabs.dhinspeccion"
|
namespace = "com.korexlabs.dhinspeccion"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
|
buildToolsVersion = "36.0.0"
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.korexlabs.dhinspeccion"
|
applicationId = "com.korexlabs.dhinspeccion"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 38
|
versionCode = 41
|
||||||
versionName = "0.19.10"
|
versionName = "0.19.13"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
status = "DRAFT",
|
status = "DRAFT",
|
||||||
occurredAt = occurredAt,
|
occurredAt = occurredAt,
|
||||||
title = "Acta de inspección ${currentVisit.code}",
|
title = "Acta de inspección ${currentVisit.code}",
|
||||||
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
|
summary = "Se inició la inspección ${currentVisit.code}.",
|
||||||
)
|
)
|
||||||
selectedAct = local
|
selectedAct = local
|
||||||
acts = acts + MobileActSummary(
|
acts = acts + MobileActSummary(
|
||||||
@@ -733,16 +733,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun prepareSelectedAct(urgency: String) {
|
fun prepareSelectedAct(urgency: String, narrative: String) {
|
||||||
val actId = selectedAct?.id ?: return
|
val actId = selectedAct?.id ?: return
|
||||||
if (urgency !in setOf("URGENT", "NON_URGENT")) {
|
if (urgency !in setOf("URGENT", "NON_URGENT")) {
|
||||||
error = "Definí la urgencia antes de cerrar el Acta."
|
error = "Definí la urgencia antes de cerrar el Acta."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val summary = narrative.trim()
|
||||||
|
if (summary.length !in 10..20000) {
|
||||||
|
error = "Describí lo actuado antes de cerrar el Acta (entre 10 y 20000 caracteres)."
|
||||||
|
return
|
||||||
|
}
|
||||||
launchBusy(mutation = true) {
|
launchBusy(mutation = true) {
|
||||||
|
offlineQueue.enqueueActSummary(actId, summary)
|
||||||
offlineQueue.enqueueActLock(actId, urgency)
|
offlineQueue.enqueueActLock(actId, urgency)
|
||||||
selectedAct = selectedAct?.copy(status = "LOCKED", urgency = urgency)
|
selectedAct = selectedAct?.copy(status = "LOCKED", urgency = urgency, summary = summary)
|
||||||
acts = acts.map { if (it.id == actId) it.copy(status = "LOCKED", urgency = urgency) else it }
|
acts = acts.map { if (it.id == actId) it.copy(status = "LOCKED", urgency = urgency, summary = summary) else it }
|
||||||
actClosure = actClosure?.copy(act = actClosure!!.act.copy(status = "LOCKED", urgency = urgency))
|
actClosure = actClosure?.copy(act = actClosure!!.act.copy(status = "LOCKED", urgency = urgency))
|
||||||
val result = offlineQueue.syncAll()
|
val result = offlineQueue.syncAll()
|
||||||
refreshOfflineStateInternal()
|
refreshOfflineStateInternal()
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ data class PrepareMobileActRequest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class UpdateMobileActRequest(
|
data class UpdateMobileActRequest(
|
||||||
val assetIds: List<String>,
|
val assetIds: List<String>? = null,
|
||||||
|
val summary: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MobileResponsibleRequest(
|
data class MobileResponsibleRequest(
|
||||||
@@ -367,7 +368,7 @@ class MobileActsRepository(context: Context) {
|
|||||||
clientGeneratedId = clientGeneratedId,
|
clientGeneratedId = clientGeneratedId,
|
||||||
occurredAt = occurredAt,
|
occurredAt = occurredAt,
|
||||||
title = "Acta de inspección $visitCode",
|
title = "Acta de inspección $visitCode",
|
||||||
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
|
summary = "Se inició la inspección $visitCode.",
|
||||||
assetIds = emptyList(),
|
assetIds = emptyList(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -376,6 +377,10 @@ class MobileActsRepository(context: Context) {
|
|||||||
return created
|
return created
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun updateSummary(actId: String, summary: String): MobileActDetail = authorized { session ->
|
||||||
|
api.updateAct("Bearer ${session.accessToken}", actId, UpdateMobileActRequest(summary = summary))
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun ensureAsset(actId: String, assetId: String): MobileActDetail {
|
suspend fun ensureAsset(actId: String, assetId: String): MobileActDetail {
|
||||||
val detail = get(actId)
|
val detail = get(actId)
|
||||||
if (detail.assets.any { it.id == assetId }) return detail
|
if (detail.assets.any { it.id == assetId }) return detail
|
||||||
|
|||||||
+37
-11
@@ -25,22 +25,36 @@ data class MobilePlanningAssetResponse(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class OpenMobileInspectionRequest(
|
data class OpenMobileInspectionRequest(
|
||||||
|
val departmentId: String,
|
||||||
val operationalAreaId: String,
|
val operationalAreaId: String,
|
||||||
|
val scopeAssetId: String,
|
||||||
val operatorCompanyId: String,
|
val operatorCompanyId: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
private interface MobileInspectionOpenApi {
|
private interface MobileInspectionOpenApi {
|
||||||
@GET("inspection-visits/mobile/planning-context/areas")
|
@GET("inspection-visits/mobile/planning-context/departments")
|
||||||
suspend fun areas(
|
suspend fun departments(
|
||||||
@Header("Authorization") authorization: String,
|
@Header("Authorization") authorization: String,
|
||||||
): MobilePlanningAssetResponse
|
): MobilePlanningAssetResponse
|
||||||
|
|
||||||
@GET("inspection-visits/mobile/planning-context/areas/{areaId}/operators")
|
@GET("inspection-visits/mobile/planning-context/departments/{departmentId}/areas")
|
||||||
suspend fun operators(
|
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,
|
@Header("Authorization") authorization: String,
|
||||||
@Path("areaId") areaId: String,
|
@Path("areaId") areaId: String,
|
||||||
): MobilePlanningAssetResponse
|
): 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")
|
@POST("inspection-visits/mobile/open")
|
||||||
suspend fun open(
|
suspend fun open(
|
||||||
@Header("Authorization") authorization: String,
|
@Header("Authorization") authorization: String,
|
||||||
@@ -61,18 +75,31 @@ class MobileInspectionOpenRepository(context: Context) {
|
|||||||
.build()
|
.build()
|
||||||
.create(MobileInspectionOpenApi::class.java)
|
.create(MobileInspectionOpenApi::class.java)
|
||||||
|
|
||||||
suspend fun areas(): MobilePlanningAssetResponse = authorized { session ->
|
suspend fun departments(): MobilePlanningAssetResponse = authorized { session ->
|
||||||
api.areas("Bearer ${session.accessToken}")
|
api.departments("Bearer ${session.accessToken}")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun operators(areaId: String): MobilePlanningAssetResponse = authorized { session ->
|
suspend fun areas(departmentId: String): MobilePlanningAssetResponse = authorized { session ->
|
||||||
api.operators("Bearer ${session.accessToken}", areaId)
|
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(
|
api.open(
|
||||||
"Bearer ${session.accessToken}",
|
"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 =
|
private suspend fun refresh(previous: StoredSession): StoredSession =
|
||||||
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
|
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,6 +256,12 @@ class OfflineMutationQueue(private val context: Context) {
|
|||||||
.toString(),
|
.toString(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suspend fun enqueueActSummary(actId: String, summary: String): String = enqueueJson(
|
||||||
|
type = "ACT_SUMMARY",
|
||||||
|
resourceId = actId,
|
||||||
|
payload = JSONObject().put("summary", summary).toString(),
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun enqueueActAsset(actId: String, assetId: String): String = enqueueJson(
|
suspend fun enqueueActAsset(actId: String, assetId: String): String = enqueueJson(
|
||||||
type = "ACT_ASSET_ENSURE",
|
type = "ACT_ASSET_ENSURE",
|
||||||
resourceId = actId,
|
resourceId = actId,
|
||||||
@@ -450,6 +456,10 @@ class OfflineMutationQueue(private val context: Context) {
|
|||||||
clientGeneratedId = operation.resourceId,
|
clientGeneratedId = operation.resourceId,
|
||||||
occurredAt = payload.getString("occurredAt"),
|
occurredAt = payload.getString("occurredAt"),
|
||||||
)
|
)
|
||||||
|
"ACT_SUMMARY" -> MobileActsRepository(context).updateSummary(
|
||||||
|
operation.resourceId,
|
||||||
|
payload.getString("summary"),
|
||||||
|
)
|
||||||
"ACT_ASSET_ENSURE" -> MobileActsRepository(context).ensureAsset(
|
"ACT_ASSET_ENSURE" -> MobileActsRepository(context).ensureAsset(
|
||||||
operation.resourceId,
|
operation.resourceId,
|
||||||
payload.getString("assetId"),
|
payload.getString("assetId"),
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ fun MobileActsScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
|
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
|
||||||
|
var actNarrative by rememberSaveable(selected?.id) {
|
||||||
|
mutableStateOf(selected?.summary.takeUnless { it == null || it.startsWith("Se inició la inspección ") || it.startsWith("Acta de inspección en curso.") }.orEmpty())
|
||||||
|
}
|
||||||
var attendance by rememberSaveable(selected?.id) {
|
var attendance by rememberSaveable(selected?.id) {
|
||||||
mutableStateOf(representativeSeed?.attendanceStatus ?: "PRESENT")
|
mutableStateOf(representativeSeed?.attendanceStatus ?: "PRESENT")
|
||||||
}
|
}
|
||||||
@@ -233,6 +236,13 @@ fun MobileActsScreen(
|
|||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
Text("Al cerrar el contenido, el Acta queda inmutable y pasa a Para firmar. La firma del inspector se toma de Mi perfil del Dashboard; en la tablet firma el acompañante de la empresa.")
|
Text("Al cerrar el contenido, el Acta queda inmutable y pasa a Para firmar. La firma del inspector se toma de Mi perfil del Dashboard; en la tablet firma el acompañante de la empresa.")
|
||||||
|
OutlinedTextField(
|
||||||
|
actNarrative,
|
||||||
|
{ actNarrative = it },
|
||||||
|
label = { Text("Descripción de lo actuado *") },
|
||||||
|
minLines = 4,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
AssistChip(
|
AssistChip(
|
||||||
onClick = { closingUrgency = "NON_URGENT" },
|
onClick = { closingUrgency = "NON_URGENT" },
|
||||||
@@ -244,8 +254,8 @@ fun MobileActsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = { model.prepareSelectedAct(closingUrgency) },
|
onClick = { model.prepareSelectedAct(closingUrgency, actNarrative) },
|
||||||
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(),
|
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank() && actNarrative.trim().length in 10..20000,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Cerrar contenido y pasar a firma") }
|
) { Text("Cerrar contenido y pasar a firma") }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,19 +47,41 @@ fun MobileHomeScreen(model: MainViewModel) {
|
|||||||
val openRepository = remember(context) { MobileInspectionOpenRepository(context) }
|
val openRepository = remember(context) { MobileInspectionOpenRepository(context) }
|
||||||
|
|
||||||
var showOpen by rememberSaveable { mutableStateOf(false) }
|
var showOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var departments by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
|
||||||
var areas 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 operators by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
|
||||||
|
var selectedDepartmentId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var selectedAreaId 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 selectedCompanyId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var loadingContext by remember { mutableStateOf(false) }
|
var loadingContext by remember { mutableStateOf(false) }
|
||||||
var opening by remember { mutableStateOf(false) }
|
var opening by remember { mutableStateOf(false) }
|
||||||
var localError by remember { mutableStateOf<String?>(null) }
|
var localError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
fun loadAreas() {
|
fun loadDepartments() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
loadingContext = true
|
loadingContext = true
|
||||||
localError = null
|
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 }
|
.onSuccess { areas = it }
|
||||||
.onFailure { localError = DhRepository.humanError(it) }
|
.onFailure { localError = DhRepository.humanError(it) }
|
||||||
loadingContext = false
|
loadingContext = false
|
||||||
@@ -68,20 +90,39 @@ fun MobileHomeScreen(model: MainViewModel) {
|
|||||||
|
|
||||||
fun chooseArea(areaId: String) {
|
fun chooseArea(areaId: String) {
|
||||||
selectedAreaId = areaId
|
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
|
selectedCompanyId = null
|
||||||
operators = emptyList()
|
operators = emptyList()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
loadingContext = true
|
loadingContext = true
|
||||||
localError = null
|
localError = null
|
||||||
runCatching { openRepository.operators(areaId).data }
|
runCatching { openRepository.operators(yacimientoId).data }
|
||||||
.onSuccess { operators = it }
|
.onSuccess {
|
||||||
|
operators = it
|
||||||
|
if (it.size == 1) selectedCompanyId = it.first().id
|
||||||
|
}
|
||||||
.onFailure { localError = DhRepository.humanError(it) }
|
.onFailure { localError = DhRepository.humanError(it) }
|
||||||
loadingContext = false
|
loadingContext = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(showOpen) {
|
LaunchedEffect(showOpen) {
|
||||||
if (showOpen && areas.isEmpty()) loadAreas()
|
if (showOpen && departments.isEmpty()) loadDepartments()
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
|
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("Abrir inspección en campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
|
|
||||||
Text("1. Área", fontWeight = FontWeight.SemiBold)
|
Text("1. Departamento", fontWeight = FontWeight.SemiBold)
|
||||||
if (loadingContext && areas.isEmpty()) CircularProgressIndicator()
|
if (loadingContext && departments.isEmpty()) CircularProgressIndicator()
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
items(areas, key = { it.id }) { area ->
|
items(departments, key = { it.id }) { department ->
|
||||||
AssistChip(
|
AssistChip(
|
||||||
onClick = { chooseArea(area.id) },
|
onClick = { chooseDepartment(department.id) },
|
||||||
label = { Text(if (area.id == selectedAreaId) "✓ ${area.name}" else area.name) },
|
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) {
|
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()) {
|
if (loadingContext && operators.isEmpty()) {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
} else if (operators.isEmpty()) {
|
} 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)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
items(operators, key = { it.id }) { company ->
|
items(operators, key = { it.id }) { company ->
|
||||||
@@ -176,12 +247,14 @@ fun MobileHomeScreen(model: MainViewModel) {
|
|||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
val departmentId = selectedDepartmentId ?: return@Button
|
||||||
val areaId = selectedAreaId ?: return@Button
|
val areaId = selectedAreaId ?: return@Button
|
||||||
|
val yacimientoId = selectedYacimientoId ?: return@Button
|
||||||
val companyId = selectedCompanyId ?: return@Button
|
val companyId = selectedCompanyId ?: return@Button
|
||||||
scope.launch {
|
scope.launch {
|
||||||
opening = true
|
opening = true
|
||||||
localError = null
|
localError = null
|
||||||
runCatching { openRepository.open(areaId, companyId) }
|
runCatching { openRepository.open(departmentId, areaId, yacimientoId, companyId) }
|
||||||
.onSuccess { opened ->
|
.onSuccess { opened ->
|
||||||
showOpen = false
|
showOpen = false
|
||||||
model.openVisit(opened.id)
|
model.openVisit(opened.id)
|
||||||
@@ -191,9 +264,9 @@ fun MobileHomeScreen(model: MainViewModel) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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(visit.code, fontWeight = FontWeight.Bold)
|
||||||
Text(visitStatusLabelEs(visit.status))
|
Text(visitStatusLabelEs(visit.status))
|
||||||
}
|
}
|
||||||
|
Text(visit.scopeAsset?.name ?: "Yacimiento sin definir", fontWeight = FontWeight.SemiBold)
|
||||||
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
|
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
|
||||||
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
|
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
|
||||||
visit.plannedStartAt?.let {
|
visit.plannedStartAt?.let {
|
||||||
|
|||||||
+14
-3
@@ -76,6 +76,9 @@ fun ModernMobileActsScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
|
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
|
||||||
|
var actNarrative by rememberSaveable(selected?.id) {
|
||||||
|
mutableStateOf(selected?.summary.takeUnless { it == null || it.startsWith("Se inició la inspección ") || it.startsWith("Acta de inspección en curso.") }.orEmpty())
|
||||||
|
}
|
||||||
var fullName by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.fullName.orEmpty()) }
|
var fullName by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.fullName.orEmpty()) }
|
||||||
var documentType by rememberSaveable(selected?.id) { mutableStateOf("DNI") }
|
var documentType by rememberSaveable(selected?.id) { mutableStateOf("DNI") }
|
||||||
var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.documentNumber.orEmpty()) }
|
var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.documentNumber.orEmpty()) }
|
||||||
@@ -258,7 +261,7 @@ fun ModernMobileActsScreen(
|
|||||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
Text(
|
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,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
|
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -307,6 +310,14 @@ fun ModernMobileActsScreen(
|
|||||||
"Al cerrar el contenido, el Acta queda inmutable y pasa a Para firmar. La firma del inspector se toma de Mi perfil del Dashboard; en la tablet sólo firma el acompañante de la empresa. La urgencia pertenece al Acta completa. La urgencia no se asigna a cada Hallazgo.",
|
"Al cerrar el contenido, el Acta queda inmutable y pasa a Para firmar. La firma del inspector se toma de Mi perfil del Dashboard; en la tablet sólo firma el acompañante de la empresa. La urgencia pertenece al Acta completa. La urgencia no se asigna a cada Hallazgo.",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
actNarrative,
|
||||||
|
{ actNarrative = it },
|
||||||
|
label = { Text("Descripción de lo actuado *") },
|
||||||
|
supportingText = { Text("Relatá la inspección y sus observaciones. Se incorpora al acta antes de sellarla.") },
|
||||||
|
minLines = 4,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
Text("Urgencia del Acta", fontWeight = FontWeight.SemiBold)
|
Text("Urgencia del Acta", fontWeight = FontWeight.SemiBold)
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
@@ -330,8 +341,8 @@ fun ModernMobileActsScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
onClick = { model.prepareSelectedAct(closingUrgency) },
|
onClick = { model.prepareSelectedAct(closingUrgency, actNarrative) },
|
||||||
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(),
|
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank() && actNarrative.trim().length in 10..20000,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Cerrar contenido y pasar a firma") }
|
) { Text("Cerrar contenido y pasar a firma") }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,9 @@ private data class ModernGeoSnapshot(
|
|||||||
@Composable
|
@Composable
|
||||||
fun ModernVisitRoot(model: MainViewModel) {
|
fun ModernVisitRoot(model: MainViewModel) {
|
||||||
val visit = model.visit ?: return
|
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)
|
val screen = runCatching { ModernVisitScreen.valueOf(screenName) }.getOrDefault(ModernVisitScreen.OVERVIEW)
|
||||||
|
|
||||||
when (screen) {
|
when (screen) {
|
||||||
@@ -938,7 +940,7 @@ private fun ModernInventoryBrowse(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = search,
|
value = search,
|
||||||
onValueChange = onSearchChange,
|
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") },
|
placeholder = { Text("Nombre, código o dato técnico") },
|
||||||
leadingIcon = { Icon(Icons.Filled.Search, null) },
|
leadingIcon = { Icon(Icons.Filled.Search, null) },
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
|
|||||||
@Test
|
@Test
|
||||||
fun debugBuildKeepsSeparateApplicationIdentity() {
|
fun debugBuildKeepsSeparateApplicationIdentity() {
|
||||||
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
|
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
|
||||||
assertEquals(38, BuildConfig.VERSION_CODE)
|
assertEquals(41, BuildConfig.VERSION_CODE)
|
||||||
assertEquals("0.19.10-debug", BuildConfig.VERSION_NAME)
|
assertEquals("0.19.13-debug", BuildConfig.VERSION_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ COPY package*.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY nest-cli.json tsconfig.json ./
|
COPY nest-cli.json tsconfig.json ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
COPY assets ./assets
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24-alpine AS runner
|
||||||
@@ -13,6 +14,7 @@ WORKDIR /app
|
|||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev && npm cache clean --force
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/assets ./assets
|
||||||
RUN mkdir -p /app/storage/asset-media/imports && chown -R node:node /app/storage
|
RUN mkdir -p /app/storage/asset-media/imports && chown -R node:node /app/storage
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,78 @@
|
|||||||
|
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||||
|
Upstream-Name: DejaVu fonts
|
||||||
|
Upstream-Author: Stepan Roh <src@users.sourceforge.net> (original author),
|
||||||
|
see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list
|
||||||
|
Source: https://dejavu-fonts.github.io/
|
||||||
|
|
||||||
|
Files: *
|
||||||
|
Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
|
||||||
|
Bitstream Vera is a trademark of Bitstream, Inc.
|
||||||
|
DejaVu changes are in public domain.
|
||||||
|
License: bitstream-vera
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of the fonts accompanying this license ("Fonts") and associated
|
||||||
|
documentation files (the "Font Software"), to reproduce and distribute the
|
||||||
|
Font Software, including without limitation the rights to use, copy, merge,
|
||||||
|
publish, distribute, and/or sell copies of the Font Software, and to permit
|
||||||
|
persons to whom the Font Software is furnished to do so, subject to the
|
||||||
|
following conditions:
|
||||||
|
.
|
||||||
|
The above copyright and trademark notices and this permission notice shall
|
||||||
|
be included in all copies of one or more of the Font Software typefaces.
|
||||||
|
.
|
||||||
|
The Font Software may be modified, altered, or added to, and in particular
|
||||||
|
the designs of glyphs or characters in the Fonts may be modified and
|
||||||
|
additional glyphs or characters may be added to the Fonts, only if the fonts
|
||||||
|
are renamed to names not containing either the words "Bitstream" or the word
|
||||||
|
"Vera".
|
||||||
|
.
|
||||||
|
This License becomes null and void to the extent applicable to Fonts or Font
|
||||||
|
Software that has been modified and is distributed under the "Bitstream
|
||||||
|
Vera" names.
|
||||||
|
.
|
||||||
|
The Font Software may be sold as part of a larger software package but no
|
||||||
|
copy of one or more of the Font Software typefaces may be sold by itself.
|
||||||
|
.
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||||
|
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||||
|
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||||
|
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
||||||
|
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
||||||
|
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||||
|
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
||||||
|
FONT SOFTWARE.
|
||||||
|
.
|
||||||
|
Except as contained in this notice, the names of Gnome, the Gnome
|
||||||
|
Foundation, and Bitstream Inc., shall not be used in advertising or
|
||||||
|
otherwise to promote the sale, use or other dealings in this Font Software
|
||||||
|
without prior written authorization from the Gnome Foundation or Bitstream
|
||||||
|
Inc., respectively. For further information, contact: fonts at gnome dot
|
||||||
|
org.
|
||||||
|
|
||||||
|
Files: debian/*
|
||||||
|
Copyright: (C) 2005-2006 Peter Cernak <pce@users.sourceforge.net>
|
||||||
|
(C) 2006-2011 Davide Viti <zinosat@tiscali.it>
|
||||||
|
(C) 2011-2013 Christian Perrier <bubulle@debian.org>
|
||||||
|
(C) 2013 Fabian Greffrath <fabian+debian@greffrath.com>
|
||||||
|
License: GPL-2+
|
||||||
|
This program is free software; you can redistribute it
|
||||||
|
and/or modify it under the terms of the GNU General Public
|
||||||
|
License as published by the Free Software Foundation; either
|
||||||
|
version 2 of the License, or (at your option) any later
|
||||||
|
version.
|
||||||
|
.
|
||||||
|
This program is distributed in the hope that it will be
|
||||||
|
useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. See the GNU General Public License for more
|
||||||
|
details.
|
||||||
|
.
|
||||||
|
You should have received a copy of the GNU General Public
|
||||||
|
License along with this package; if not, write to the Free
|
||||||
|
Software Foundation, Inc., 51 Franklin St, Fifth Floor,
|
||||||
|
Boston, MA 02110-1301 USA
|
||||||
|
.
|
||||||
|
On Debian systems, the full text of the GNU General Public
|
||||||
|
License version 2 can be found in the file
|
||||||
|
/usr/share/common-licenses/GPL-2'.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Generated
+742
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.29.0-8",
|
"version": "0.29.0-17",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.29.0-8",
|
"version": "0.29.0-17",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
@@ -21,9 +21,11 @@
|
|||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
|
"pdfkit": "^0.20.2",
|
||||||
"pg": "^8.0.0",
|
"pg": "^8.0.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
|
"sharp": "^0.35.4",
|
||||||
"typeorm": "^0.3.0"
|
"typeorm": "^0.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -32,6 +34,7 @@
|
|||||||
"@types/cookie-parser": "^1.4.9",
|
"@types/cookie-parser": "^1.4.9",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/pdfkit": "^0.17.6",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.9.0"
|
"typescript": "^5.9.0"
|
||||||
@@ -216,6 +219,16 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.11.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||||
|
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@epic-web/invariant": {
|
"node_modules/@epic-web/invariant": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||||
@@ -664,6 +677,506 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@img/colour": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@img/sharp-wasm32": "0.35.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.11.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@img/sharp-wasm32": "0.35.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@inquirer/ansi": {
|
"node_modules/@inquirer/ansi": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
|
||||||
@@ -1459,6 +1972,30 @@
|
|||||||
"typeorm": "^0.3.0 || ^1.0.0-dev"
|
"typeorm": "^0.3.0 || ^1.0.0-dev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/ciphers": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@phc/format": {
|
"node_modules/@phc/format": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
||||||
@@ -1484,6 +2021,15 @@
|
|||||||
"integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==",
|
"integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/helpers": {
|
||||||
|
"version": "0.5.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||||
|
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tokenizer/inflate": {
|
"node_modules/@tokenizer/inflate": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
||||||
@@ -1659,6 +2205,16 @@
|
|||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pdfkit": {
|
||||||
|
"version": "0.17.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
|
||||||
|
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.15.1",
|
"version": "6.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||||
@@ -2185,6 +2741,15 @@
|
|||||||
"concat-map": "0.0.1"
|
"concat-map": "0.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/brotli": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.8",
|
"version": "4.28.8",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
||||||
@@ -2805,6 +3370,21 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dfa": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/diff": {
|
"node_modules/diff": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||||
@@ -3156,7 +3736,6 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-json-stable-stringify": {
|
"node_modules/fast-json-stable-stringify": {
|
||||||
@@ -3189,6 +3768,12 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/file-type": {
|
"node_modules/file-type": {
|
||||||
"version": "21.3.4",
|
"version": "21.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
|
||||||
@@ -3228,6 +3813,32 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fontkit": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.12",
|
||||||
|
"brotli": "^1.3.2",
|
||||||
|
"clone": "^2.1.2",
|
||||||
|
"dfa": "^1.2.0",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"restructure": "^3.0.0",
|
||||||
|
"tiny-inflate": "^1.0.3",
|
||||||
|
"unicode-properties": "^1.4.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fontkit/node_modules/clone": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -3906,6 +4517,25 @@
|
|||||||
"integrity": "sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==",
|
"integrity": "sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/linebreak": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "0.0.8",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/linebreak/node_modules/base64-js": {
|
||||||
|
"version": "0.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||||
|
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lines-and-columns": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
@@ -4388,6 +5018,12 @@
|
|||||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.0.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "0.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||||
|
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -4475,6 +5111,20 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pdfkit": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-Q/w03ICAQyXfHNfTsg1udp0ADerdBN0s7a6XSPL7J7Ro6ABnafoBOCDBstIO9U02mvzHEV8HrvFIw5iV5ejIRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/ciphers": "^1.3.0",
|
||||||
|
"@noble/hashes": "^1.8.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
|
"fontkit": "^2.0.4",
|
||||||
|
"linebreak": "^1.1.0",
|
||||||
|
"png-js": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pg": {
|
"node_modules/pg": {
|
||||||
"version": "8.23.0",
|
"version": "8.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
||||||
@@ -4594,6 +5244,14 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/png-js": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==",
|
||||||
|
"dependencies": {
|
||||||
|
"fflate": "^0.8.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -4793,6 +5451,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/restructure": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/router": {
|
"node_modules/router": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||||
@@ -4997,6 +5661,55 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@img/colour": "^1.1.0",
|
||||||
|
"detect-libc": "^2.1.2",
|
||||||
|
"semver": "^7.8.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.35.4",
|
||||||
|
"@img/sharp-darwin-x64": "0.35.4",
|
||||||
|
"@img/sharp-freebsd-wasm32": "0.35.4",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.3.3",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.3.3",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.3.3",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.3.3",
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.3.3",
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.3.3",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.3.3",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.3.3",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
|
||||||
|
"@img/sharp-linux-arm": "0.35.4",
|
||||||
|
"@img/sharp-linux-arm64": "0.35.4",
|
||||||
|
"@img/sharp-linux-ppc64": "0.35.4",
|
||||||
|
"@img/sharp-linux-riscv64": "0.35.4",
|
||||||
|
"@img/sharp-linux-s390x": "0.35.4",
|
||||||
|
"@img/sharp-linux-x64": "0.35.4",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.35.4",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.35.4",
|
||||||
|
"@img/sharp-webcontainers-wasm32": "0.35.4",
|
||||||
|
"@img/sharp-win32-arm64": "0.35.4",
|
||||||
|
"@img/sharp-win32-ia32": "0.35.4",
|
||||||
|
"@img/sharp-win32-x64": "0.35.4"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -5437,6 +6150,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-inflate": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/to-buffer": {
|
"node_modules/to-buffer": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz",
|
||||||
@@ -5887,6 +6606,26 @@
|
|||||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/unicode-properties": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unicode-trie": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^0.2.5",
|
||||||
|
"tiny-inflate": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/universalify": {
|
"node_modules/universalify": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.29.0-8",
|
"version": "0.29.0-17",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -28,9 +28,11 @@
|
|||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
|
"pdfkit": "^0.20.2",
|
||||||
"pg": "^8.0.0",
|
"pg": "^8.0.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
|
"sharp": "^0.35.4",
|
||||||
"typeorm": "^0.3.0"
|
"typeorm": "^0.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -39,6 +41,7 @@
|
|||||||
"@types/cookie-parser": "^1.4.9",
|
"@types/cookie-parser": "^1.4.9",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/pdfkit": "^0.17.6",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.9.0"
|
"typescript": "^5.9.0"
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
|
|
||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
import { Controller, Get, Param, ParseUUIDPipe, Query, Res } from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
|
||||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
import { ActAdministrationService } from './act-administration.service';
|
||||||
import { ActAdministrationService, MAX_ACT_RESPONSE_BYTES, type UploadedActResponseFile } from './act-administration.service';
|
|
||||||
import { CreateActCompanyResponseDto } from './dto/create-act-company-response.dto';
|
|
||||||
import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto';
|
import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto';
|
||||||
import { SetActResponseDeadlineDto } from './dto/set-act-response-deadline.dto';
|
|
||||||
|
|
||||||
@Controller('act-administration')
|
@Controller('act-administration')
|
||||||
export class ActAdministrationQueueController {
|
export class ActAdministrationQueueController {
|
||||||
@@ -21,11 +16,8 @@ export class ActAdministrationQueueController {
|
|||||||
export class ActAdministrationController {
|
export class ActAdministrationController {
|
||||||
constructor(private readonly administration: ActAdministrationService) {}
|
constructor(private readonly administration: ActAdministrationService) {}
|
||||||
@Get() @RequirePermissions('inspection_acts.read') get(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string) { return this.administration.detail(actId); }
|
@Get() @RequirePermissions('inspection_acts.read') get(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string) { return this.administration.detail(actId); }
|
||||||
@Patch('deadline') @RequirePermissions('inspection_findings.follow_up')
|
// Historic deadlines and responses remain readable here.
|
||||||
setDeadline(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: SetActResponseDeadlineDto, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.setDeadline(actId, dto, principal, request); }
|
// New administrative entries are recorded on the related Informe.
|
||||||
@Post('responses') @RequirePermissions('inspection_findings.follow_up')
|
|
||||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_ACT_RESPONSE_BYTES, files: 1 } }))
|
|
||||||
addResponse(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: CreateActCompanyResponseDto, @UploadedFile() file: UploadedActResponseFile | undefined, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.addResponse(actId, dto, file, principal, request); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('act-company-responses')
|
@Controller('act-company-responses')
|
||||||
|
|||||||
@@ -18,5 +18,6 @@ import { FieldBriefingService } from './field-briefing.service';
|
|||||||
FieldBriefingController,
|
FieldBriefingController,
|
||||||
],
|
],
|
||||||
providers: [ActAdministrationService, FieldBriefingService],
|
providers: [ActAdministrationService, FieldBriefingService],
|
||||||
|
exports: [ActAdministrationService],
|
||||||
})
|
})
|
||||||
export class ActAdministrationModule {}
|
export class ActAdministrationModule {}
|
||||||
|
|||||||
@@ -114,17 +114,46 @@ export class ActAdministrationService {
|
|||||||
return act;
|
return act;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setDeadline(actId: string, dto: SetActResponseDeadlineDto, principal: AuthPrincipal, request: RequestWithContext) {
|
async setDeadline(
|
||||||
|
actId: string,
|
||||||
|
dto: SetActResponseDeadlineDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
reportId: string | null = null,
|
||||||
|
) {
|
||||||
const act = await this.ensureClosedAct(actId);
|
const act = await this.ensureClosedAct(actId);
|
||||||
const rows = await this.dataSource.query(
|
return this.dataSource.transaction(async (manager) => {
|
||||||
`INSERT INTO inspection_act_deadline_events (id, act_id, response_due_on, reason, created_by) VALUES ($1,$2,$3,$4,$5) RETURNING id, response_due_on AS "responseDueOn", reason, created_at AS "createdAt"`,
|
const rows = await manager.query(
|
||||||
[randomUUID(), actId, dto.responseDueOn, dto.reason, principal.userId],
|
`INSERT INTO inspection_act_deadline_events (id, act_id, report_id, response_due_on, reason, created_by)
|
||||||
);
|
VALUES ($1,$2,$3,$4,$5,$6)
|
||||||
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_RESPONSE_DEADLINE_SET', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, responseDueOn: dto.responseDueOn, reason: dto.reason } });
|
RETURNING id, report_id AS "reportId", response_due_on AS "responseDueOn", reason, created_at AS "createdAt"`,
|
||||||
return rows[0];
|
[randomUUID(), actId, reportId, dto.responseDueOn, dto.reason, principal.userId],
|
||||||
|
);
|
||||||
|
const projected = await manager.query(
|
||||||
|
`UPDATE inspection_findings
|
||||||
|
SET correction_due_on=$2, updated_by=$3, updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE act_id=$1 AND status<>'VOIDED'
|
||||||
|
RETURNING id`,
|
||||||
|
[actId, dto.responseDueOn, principal.userId],
|
||||||
|
) as Array<{ id: string }>;
|
||||||
|
await this.audit.record({
|
||||||
|
actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_RESPONSE_DEADLINE_SET',
|
||||||
|
entityType: 'inspection_act', entityId: actId, requestId: request.requestId,
|
||||||
|
afterData: { actCode: act.code, reportId, responseDueOn: dto.responseDueOn, reason: dto.reason },
|
||||||
|
metadata: { reportId, projectedFindingCount: projected.length, sharedDeadline: true },
|
||||||
|
}, manager);
|
||||||
|
return { ...rows[0], projectedFindingCount: projected.length };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async addResponse(actId: string, dto: CreateActCompanyResponseDto, file: UploadedActResponseFile | undefined, principal: AuthPrincipal, request: RequestWithContext) {
|
async addResponse(
|
||||||
|
actId: string,
|
||||||
|
dto: CreateActCompanyResponseDto,
|
||||||
|
file: UploadedActResponseFile | undefined,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
reportId: string | null = null,
|
||||||
|
) {
|
||||||
const act = await this.ensureClosedAct(actId);
|
const act = await this.ensureClosedAct(actId);
|
||||||
let storedName: string | null = null;
|
let storedName: string | null = null;
|
||||||
let sha256: string | null = null;
|
let sha256: string | null = null;
|
||||||
@@ -138,12 +167,12 @@ export class ActAdministrationService {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const rows = await this.dataSource.query(
|
const rows = await this.dataSource.query(
|
||||||
`INSERT INTO inspection_act_company_responses (id, act_id, received_on, details, committed_correction_on, contact_name, contact_email, original_name, stored_name, mime_type, size_bytes, sha256, created_by)
|
`INSERT INTO inspection_act_company_responses (id, act_id, report_id, received_on, details, committed_correction_on, contact_name, contact_email, original_name, stored_name, mime_type, size_bytes, sha256, created_by)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||||
RETURNING id, received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", size_bytes AS "sizeBytes", sha256, created_at AS "createdAt"`,
|
RETURNING id, report_id AS "reportId", received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", size_bytes AS "sizeBytes", sha256, created_at AS "createdAt"`,
|
||||||
[randomUUID(), actId, dto.receivedOn, dto.details ?? null, dto.committedCorrectionOn ?? null, dto.contactName ?? null, dto.contactEmail ?? null, file?.originalname ?? null, storedName, file ? 'application/pdf' : null, file?.size ?? null, sha256, principal.userId],
|
[randomUUID(), actId, reportId, dto.receivedOn, dto.details ?? null, dto.committedCorrectionOn ?? null, dto.contactName ?? null, dto.contactEmail ?? null, file?.originalname ?? null, storedName, file ? 'application/pdf' : null, file?.size ?? null, sha256, principal.userId],
|
||||||
);
|
);
|
||||||
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_COMPANY_RESPONSE_RECORDED', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, receivedOn: dto.receivedOn, committedCorrectionOn: dto.committedCorrectionOn ?? null, hasPdf: Boolean(file), sha256 } });
|
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_COMPANY_RESPONSE_RECORDED', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, reportId, receivedOn: dto.receivedOn, committedCorrectionOn: dto.committedCorrectionOn ?? null, hasPdf: Boolean(file), sha256 }, metadata: { reportId } });
|
||||||
return rows[0];
|
return rows[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined);
|
if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined);
|
||||||
@@ -151,8 +180,13 @@ export class ActAdministrationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async responseContent(responseId: string) {
|
async responseContent(responseId: string, reportId?: string) {
|
||||||
const rows = await this.dataSource.query(`SELECT original_name AS "originalName", stored_name AS "storedName", size_bytes AS "sizeBytes" FROM inspection_act_company_responses WHERE id = $1 AND stored_name IS NOT NULL`, [responseId]);
|
const rows = await this.dataSource.query(
|
||||||
|
`SELECT original_name AS "originalName", stored_name AS "storedName", size_bytes AS "sizeBytes"
|
||||||
|
FROM inspection_act_company_responses
|
||||||
|
WHERE id=$1 AND stored_name IS NOT NULL AND ($2::uuid IS NULL OR report_id=$2::uuid)`,
|
||||||
|
[responseId, reportId ?? null],
|
||||||
|
);
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
if (!row) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'PDF de respuesta inexistente.' });
|
if (!row) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'PDF de respuesta inexistente.' });
|
||||||
const filePath = join(STORAGE, row.storedName);
|
const filePath = join(STORAGE, row.storedName);
|
||||||
|
|||||||
@@ -63,3 +63,25 @@ export class MapAssetsController {
|
|||||||
return this.geometries.map(query);
|
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,
|
geometry: row.geometry,
|
||||||
properties: {
|
properties: {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
entityId: row.id,
|
||||||
|
entityKind: 'ASSET',
|
||||||
code: row.code,
|
code: row.code,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
typeId: row.typeId,
|
typeId: row.typeId,
|
||||||
@@ -242,12 +244,230 @@ export class AssetGeometriesService {
|
|||||||
accuracyM: row.accuracyM == null ? null : Number(row.accuracyM),
|
accuracyM: row.accuracyM == null ? null : Number(row.accuracyM),
|
||||||
capturedAt: row.capturedAt,
|
capturedAt: row.capturedAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
|
href: `/inventarios/${row.id}`,
|
||||||
|
contextLine: row.parentName ? `Depende de ${row.parentName}` : null,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
meta: { count: visible.length, truncated },
|
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(
|
private async requireAsset(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { AssetsService } from './assets.service';
|
|||||||
import {
|
import {
|
||||||
AssetGeometriesController,
|
AssetGeometriesController,
|
||||||
MapAssetsController,
|
MapAssetsController,
|
||||||
|
MapDocumentsController,
|
||||||
|
MapOperationalContextController,
|
||||||
} from './asset-geometries.controller';
|
} from './asset-geometries.controller';
|
||||||
import { AssetGeometriesService } from './asset-geometries.service';
|
import { AssetGeometriesService } from './asset-geometries.service';
|
||||||
import { AssetHistoryController } from './asset-history.controller';
|
import { AssetHistoryController } from './asset-history.controller';
|
||||||
@@ -50,6 +52,8 @@ import { InventoryBrowserService } from './inventory-browser.service';
|
|||||||
FieldInventoryMergeController,
|
FieldInventoryMergeController,
|
||||||
AssetGeometriesController,
|
AssetGeometriesController,
|
||||||
MapAssetsController,
|
MapAssetsController,
|
||||||
|
MapOperationalContextController,
|
||||||
|
MapDocumentsController,
|
||||||
AssetHistoryController,
|
AssetHistoryController,
|
||||||
AssetMediaController,
|
AssetMediaController,
|
||||||
AssetProvenanceController,
|
AssetProvenanceController,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class F69ConsolidatedActDocument1790131800000 implements MigrationInterface {
|
||||||
|
name = 'F69ConsolidatedActDocument1790131800000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE inspection_act_consolidated_pdf_artifacts (
|
||||||
|
act_id uuid PRIMARY KEY REFERENCES inspection_acts(id) ON DELETE CASCADE,
|
||||||
|
stored_name varchar(255) NOT NULL,
|
||||||
|
original_name varchar(255) NOT NULL,
|
||||||
|
size_bytes integer NOT NULL CHECK (size_bytes > 0),
|
||||||
|
sha256 char(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
|
||||||
|
generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE inspection_report_consolidated_word_artifacts (
|
||||||
|
report_id uuid PRIMARY KEY REFERENCES inspection_reports(id) ON DELETE CASCADE,
|
||||||
|
stored_name varchar(255) NOT NULL,
|
||||||
|
original_name varchar(255) NOT NULL,
|
||||||
|
size_bytes integer NOT NULL CHECK (size_bytes > 0),
|
||||||
|
sha256 char(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
|
||||||
|
generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('DROP TABLE inspection_report_consolidated_word_artifacts');
|
||||||
|
await queryRunner.query('DROP TABLE inspection_act_consolidated_pdf_artifacts');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class F69ConsolidatedDocumentRevisions1790135400000 implements MigrationInterface {
|
||||||
|
name = 'F69ConsolidatedDocumentRevisions1790135400000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE inspection_act_consolidated_pdf_revisions (
|
||||||
|
act_id uuid NOT NULL REFERENCES inspection_acts(id) ON DELETE CASCADE,
|
||||||
|
template_version smallint NOT NULL CHECK (template_version >= 1),
|
||||||
|
stored_name varchar(255) NOT NULL,
|
||||||
|
original_name varchar(255) NOT NULL,
|
||||||
|
size_bytes integer NOT NULL CHECK (size_bytes > 0),
|
||||||
|
sha256 char(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
|
||||||
|
generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (act_id,template_version)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO inspection_act_consolidated_pdf_revisions(
|
||||||
|
act_id,template_version,stored_name,original_name,size_bytes,sha256,generated_at)
|
||||||
|
SELECT act_id,1,stored_name,original_name,size_bytes,sha256,generated_at
|
||||||
|
FROM inspection_act_consolidated_pdf_artifacts
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE inspection_report_consolidated_word_revisions (
|
||||||
|
report_id uuid NOT NULL REFERENCES inspection_reports(id) ON DELETE CASCADE,
|
||||||
|
template_version smallint NOT NULL CHECK (template_version >= 1),
|
||||||
|
stored_name varchar(255) NOT NULL,
|
||||||
|
original_name varchar(255) NOT NULL,
|
||||||
|
size_bytes integer NOT NULL CHECK (size_bytes > 0),
|
||||||
|
sha256 char(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
|
||||||
|
generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (report_id,template_version)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO inspection_report_consolidated_word_revisions(
|
||||||
|
report_id,template_version,stored_name,original_name,size_bytes,sha256,generated_at)
|
||||||
|
SELECT report_id,1,stored_name,original_name,size_bytes,sha256,generated_at
|
||||||
|
FROM inspection_report_consolidated_word_artifacts
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('DROP TABLE inspection_report_consolidated_word_revisions');
|
||||||
|
await queryRunner.query('DROP TABLE inspection_act_consolidated_pdf_revisions');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class F611ReportResponseWorkflow1790139000000 implements MigrationInterface {
|
||||||
|
name = 'F611ReportResponseWorkflow1790139000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events ADD COLUMN IF NOT EXISTS report_id uuid`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_company_responses ADD COLUMN IF NOT EXISTS report_id uuid`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events ADD CONSTRAINT fk_act_deadline_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE RESTRICT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_company_responses ADD CONSTRAINT fk_act_response_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE RESTRICT`);
|
||||||
|
await queryRunner.query(`CREATE INDEX idx_act_deadline_events_report_created ON inspection_act_deadline_events(report_id, created_at DESC)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX idx_act_company_responses_report_received ON inspection_act_company_responses(report_id, received_on DESC, created_at DESC)`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION validate_report_act_relation()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.report_id IS NOT NULL AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM inspection_reports report
|
||||||
|
WHERE report.id=NEW.report_id AND report.act_id=NEW.act_id
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'El Informe no corresponde al Acta indicada';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE TRIGGER trg_act_deadline_report_relation BEFORE INSERT ON inspection_act_deadline_events FOR EACH ROW EXECUTE FUNCTION validate_report_act_relation()`);
|
||||||
|
await queryRunner.query(`CREATE TRIGGER trg_act_response_report_relation BEFORE INSERT ON inspection_act_company_responses FOR EACH ROW EXECUTE FUNCTION validate_report_act_relation()`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_response_report_relation ON inspection_act_company_responses`);
|
||||||
|
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_deadline_report_relation ON inspection_act_deadline_events`);
|
||||||
|
await queryRunner.query(`DROP FUNCTION IF EXISTS validate_report_act_relation()`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS idx_act_company_responses_report_received`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS idx_act_deadline_events_report_created`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_company_responses DROP CONSTRAINT IF EXISTS fk_act_response_report`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events DROP CONSTRAINT IF EXISTS fk_act_deadline_report`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_company_responses DROP COLUMN IF EXISTS report_id`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events DROP COLUMN IF EXISTS report_id`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
code: string;
|
||||||
status: string;
|
status: string;
|
||||||
pdfStatus: string;
|
pdfStatus: string;
|
||||||
|
wordStatus: string;
|
||||||
|
gedoIfIdentifier: string | null;
|
||||||
|
gedoOfficializedAt: Date | null;
|
||||||
generatedAt: Date;
|
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 {
|
export interface InspectionActListItem {
|
||||||
id: string;
|
id: string;
|
||||||
visitId: string;
|
visitId: string;
|
||||||
@@ -94,6 +107,7 @@ export interface InspectionActListItem {
|
|||||||
findingCount: number;
|
findingCount: number;
|
||||||
companies: ActContextAsset[];
|
companies: ActContextAsset[];
|
||||||
areas: ActContextAsset[];
|
areas: ActContextAsset[];
|
||||||
|
context: ActTerritorialContext;
|
||||||
report: ActReportSummary | null;
|
report: ActReportSummary | null;
|
||||||
createdBy: ActPerson | null;
|
createdBy: ActPerson | null;
|
||||||
updatedBy: ActPerson | null;
|
updatedBy: ActPerson | null;
|
||||||
@@ -547,11 +561,23 @@ export class InspectionActsService {
|
|||||||
COALESCE(finding_count.total, 0)::integer AS "findingCount",
|
COALESCE(finding_count.total, 0)::integer AS "findingCount",
|
||||||
COALESCE(context.companies, '[]'::jsonb) AS companies,
|
COALESCE(context.companies, '[]'::jsonb) AS companies,
|
||||||
COALESCE(context.areas, '[]'::jsonb) AS areas,
|
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(
|
CASE WHEN report.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
'id', report.id,
|
'id', report.id,
|
||||||
'code', report.code,
|
'code', report.code,
|
||||||
'status', report.status,
|
'status', report.status,
|
||||||
'pdfStatus', report.pdf_status,
|
'pdfStatus', report.pdf_status,
|
||||||
|
'wordStatus', report.word_status,
|
||||||
|
'gedoIfIdentifier', report.gedo_if_identifier,
|
||||||
|
'gedoOfficializedAt', report.gedo_officialized_at,
|
||||||
'generatedAt', report.generated_at
|
'generatedAt', report.generated_at
|
||||||
) END AS report,
|
) END AS report,
|
||||||
CASE WHEN creator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
CASE WHEN creator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
@@ -580,10 +606,64 @@ export class InspectionActsService {
|
|||||||
) END AS companies,
|
) END AS companies,
|
||||||
CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
|
CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
|
||||||
JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name)
|
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
|
FROM inspection_visits context_visit
|
||||||
LEFT JOIN assets company ON company.id=context_visit.operator_company_id
|
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 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
|
WHERE context_visit.id=act.visit_id
|
||||||
) context ON true
|
) context ON true
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
|
|||||||
@@ -264,7 +264,6 @@ export class CompanySignatureInviteService {
|
|||||||
const invite = await this.resolveToken(this.dataSource.manager, token, false);
|
const invite = await this.resolveToken(this.dataSource.manager, token, false);
|
||||||
const locked = invite.lockedSnapshot ?? {};
|
const locked = invite.lockedSnapshot ?? {};
|
||||||
const act = this.record(locked.act);
|
const act = this.record(locked.act);
|
||||||
const inventories = this.records(locked.inventories);
|
|
||||||
const findings = this.records(locked.findings).map((finding) => ({
|
const findings = this.records(locked.findings).map((finding) => ({
|
||||||
id: finding.id,
|
id: finding.id,
|
||||||
code: finding.code,
|
code: finding.code,
|
||||||
@@ -296,12 +295,6 @@ export class CompanySignatureInviteService {
|
|||||||
documentNumber: invite.recipientDocumentNumber,
|
documentNumber: invite.recipientDocumentNumber,
|
||||||
position: invite.recipientPosition,
|
position: invite.recipientPosition,
|
||||||
},
|
},
|
||||||
inventories: inventories.map((inventory) => ({
|
|
||||||
id: inventory.id,
|
|
||||||
code: inventory.code,
|
|
||||||
name: inventory.name,
|
|
||||||
typeName: inventory.typeName ?? inventory.typeCode ?? null,
|
|
||||||
})),
|
|
||||||
findings,
|
findings,
|
||||||
consent: REMOTE_COMPANY_CONSENT,
|
consent: REMOTE_COMPANY_CONSENT,
|
||||||
allowedActions: ['SIGN_CONFORMITY', 'SIGN_DISSENT', 'REFUSE'],
|
allowedActions: ['SIGN_CONFORMITY', 'SIGN_DISSENT', 'REFUSE'],
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
type UploadedInspectionSignatureFile,
|
type UploadedInspectionSignatureFile,
|
||||||
} from './inspection-signature-file';
|
} from './inspection-signature-file';
|
||||||
|
|
||||||
const CLOSURE_SCHEMA_VERSION = 'DH-ACT-LIFECYCLE-V4';
|
const CLOSURE_SCHEMA_VERSION = 'DH-ACT-LIFECYCLE-V5';
|
||||||
const CONSENT_VERSION = 'F4-1';
|
const CONSENT_VERSION = 'F4-1';
|
||||||
const INSPECTOR_CONSENT = 'Declaro que revisé el contenido del acta bloqueada y que esta firma deja constancia de mi intervención como inspector/a.';
|
const INSPECTOR_CONSENT = 'Declaro que revisé el contenido del acta bloqueada y que esta firma deja constancia de mi intervención como inspector/a.';
|
||||||
const COMPANY_CONSENT = 'Declaro haber accedido al contenido íntegro del acta bloqueada y que esta firma electrónica deja constancia de mi recepción y manifestación, sin alterar el contenido del acta.';
|
const COMPANY_CONSENT = 'Declaro haber accedido al contenido íntegro del acta bloqueada y que esta firma electrónica deja constancia de mi recepción y manifestación, sin alterar el contenido del acta.';
|
||||||
@@ -896,6 +896,8 @@ export class InspectionClosingService {
|
|||||||
'id',act.id,
|
'id',act.id,
|
||||||
'code',act.code,
|
'code',act.code,
|
||||||
'status',act.status,
|
'status',act.status,
|
||||||
|
'actYear',act.act_year,
|
||||||
|
'actNumber',act.act_number,
|
||||||
'occurredAt',act.occurred_at,
|
'occurredAt',act.occurred_at,
|
||||||
'title',act.title,
|
'title',act.title,
|
||||||
'summary',act.summary,
|
'summary',act.summary,
|
||||||
@@ -910,6 +912,7 @@ export class InspectionClosingService {
|
|||||||
'id',visit.id,
|
'id',visit.id,
|
||||||
'code',visit.code,
|
'code',visit.code,
|
||||||
'status',visit.status,
|
'status',visit.status,
|
||||||
|
'scopeAssetId',visit.scope_asset_id,
|
||||||
'operationalAreaId',visit.operational_area_id,
|
'operationalAreaId',visit.operational_area_id,
|
||||||
'operatorCompanyId',visit.operator_company_id,
|
'operatorCompanyId',visit.operator_company_id,
|
||||||
'leadInspectorUserId',visit.lead_inspector_user_id,
|
'leadInspectorUserId',visit.lead_inspector_user_id,
|
||||||
@@ -922,43 +925,74 @@ export class InspectionClosingService {
|
|||||||
`, [actId]) as Array<{ act: Record<string, unknown> }>;
|
`, [actId]) as Array<{ act: Record<string, unknown> }>;
|
||||||
if (!act) throw actNotFound();
|
if (!act) throw actNotFound();
|
||||||
const responsible = await this.requireResponsible(manager, actId);
|
const responsible = await this.requireResponsible(manager, actId);
|
||||||
|
const [contextRow] = await manager.query(`
|
||||||
|
SELECT
|
||||||
|
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END AS company,
|
||||||
|
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 scope.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',scope.id,'code',scope.code,'name',scope.name,'typeCode',scope_type.code,'typeName',scope_type.name) END AS scope,
|
||||||
|
CASE WHEN lead.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',lead.id,'username',lead.username,'firstName',lead.first_name,'lastName',lead.last_name,'email',lead.email) END AS "leadInspector"
|
||||||
|
FROM inspection_acts act
|
||||||
|
JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||||
|
LEFT JOIN assets company ON company.id=visit.operator_company_id
|
||||||
|
LEFT JOIN assets area ON area.id=visit.operational_area_id
|
||||||
|
LEFT JOIN assets department ON department.id=area.parent_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
|
||||||
|
LEFT JOIN users lead ON lead.id=visit.lead_inspector_user_id
|
||||||
|
WHERE act.id=$1
|
||||||
|
`, [actId]) as Array<Record<string, unknown>>;
|
||||||
|
const inspectors = await manager.query(`
|
||||||
|
SELECT member.id,member.username,member.first_name AS "firstName",member.last_name AS "lastName",member.email
|
||||||
|
FROM inspection_acts act
|
||||||
|
JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||||
|
JOIN inspection_visit_members link ON link.visit_id=act.visit_id AND link.included=true
|
||||||
|
JOIN users member ON member.id=link.user_id
|
||||||
|
WHERE act.id=$1
|
||||||
|
ORDER BY CASE WHEN member.id=visit.lead_inspector_user_id THEN 0 ELSE 1 END,
|
||||||
|
member.last_name,member.first_name,member.username
|
||||||
|
`, [actId]) as Array<Record<string, unknown>>;
|
||||||
|
const context = { ...(contextRow ?? {}), inspectors };
|
||||||
const inventories = await manager.query(`
|
const inventories = await manager.query(`
|
||||||
SELECT asset.id,asset.code,asset.name,asset.current_version AS "currentVersion",
|
WITH RECURSIVE selected AS (
|
||||||
type.code AS "typeCode",type.name AS "typeName"
|
SELECT asset.id,asset.code,asset.name,asset.common_name,asset.parent_id,asset.current_version,type.code AS type_code,type.name AS type_name,
|
||||||
FROM inspection_act_assets link
|
family.code AS family_code,family.name AS family_name
|
||||||
JOIN assets asset ON asset.id=link.asset_id
|
FROM inspection_act_assets link
|
||||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
JOIN assets asset ON asset.id=link.asset_id
|
||||||
WHERE link.act_id=$1 AND link.included=true
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
ORDER BY asset.code,asset.id
|
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
||||||
|
WHERE link.act_id=$1 AND link.included=true
|
||||||
|
AND EXISTS (SELECT 1 FROM inspection_findings finding WHERE finding.act_id=link.act_id AND finding.asset_id=asset.id AND finding.status<>'VOIDED')
|
||||||
|
), lineage AS (
|
||||||
|
SELECT selected.id AS root_id,selected.id,selected.code,selected.name,selected.type_code,selected.type_name,selected.parent_id,0 AS depth FROM selected
|
||||||
|
UNION ALL
|
||||||
|
SELECT lineage.root_id,parent.id,parent.code,parent.name,parent_type.code,parent_type.name,parent.parent_id,lineage.depth+1
|
||||||
|
FROM lineage JOIN assets parent ON parent.id=lineage.parent_id JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id
|
||||||
|
WHERE lineage.depth<8
|
||||||
|
)
|
||||||
|
SELECT selected.id,selected.code,selected.name,selected.common_name AS "commonName",selected.current_version AS "currentVersion",
|
||||||
|
selected.type_code AS "typeCode",selected.type_name AS "typeName",
|
||||||
|
selected.family_code AS "installationTypeCode",selected.family_name AS "installationTypeName",
|
||||||
|
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',lineage.id,'code',lineage.code,'name',lineage.name,'typeCode',lineage.type_code,'typeName',lineage.type_name) ORDER BY lineage.depth DESC) FROM lineage WHERE lineage.root_id=selected.id),'[]'::jsonb) AS path
|
||||||
|
FROM selected ORDER BY selected.code,selected.id
|
||||||
`, [actId]) as Array<Record<string, unknown>>;
|
`, [actId]) as Array<Record<string, unknown>>;
|
||||||
const findings = await manager.query(`
|
const findings = await manager.query(`
|
||||||
SELECT
|
SELECT finding.id,finding.finding_number AS "findingNumber",finding.code,finding.status,
|
||||||
finding.id,
|
finding.asset_id AS "assetId",finding.catalog_item_id AS "catalogItemId",finding.title,finding.description,
|
||||||
finding.finding_number AS "findingNumber",
|
finding.legal_basis AS "legalBasis",finding.glossary,finding.catalog_revision AS "catalogRevision",
|
||||||
finding.code,
|
finding.suggested_severity AS "suggestedSeverity",finding.severity,
|
||||||
finding.status,
|
finding.is_recurrence AS "isRecurrence",finding.recurrence_of_finding_id AS "recurrenceOfFindingId",
|
||||||
finding.asset_id AS "assetId",
|
finding.correction_due_on AS "correctionDueOn",finding.current_version AS "currentVersion",
|
||||||
finding.catalog_item_id AS "catalogItemId",
|
CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',catalog.id,'code',catalog.code,'sourceNumber',catalog.source_number,'title',catalog.title,'categoryName',category.name,'revision',finding.catalog_revision) END AS catalog,
|
||||||
finding.title,
|
CASE WHEN antecedent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',antecedent.id,'code',antecedent.code,'title',antecedent.title) END AS recurrence
|
||||||
finding.description,
|
|
||||||
finding.legal_basis AS "legalBasis",
|
|
||||||
finding.severity,
|
|
||||||
finding.is_recurrence AS "isRecurrence",
|
|
||||||
finding.recurrence_of_finding_id AS "recurrenceOfFindingId",
|
|
||||||
finding.correction_due_on AS "correctionDueOn",
|
|
||||||
finding.current_version AS "currentVersion"
|
|
||||||
FROM inspection_findings finding
|
FROM inspection_findings finding
|
||||||
|
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 inspection_findings antecedent ON antecedent.id=finding.recurrence_of_finding_id
|
||||||
WHERE finding.act_id=$1 AND finding.status<>'VOIDED'
|
WHERE finding.act_id=$1 AND finding.status<>'VOIDED'
|
||||||
ORDER BY finding.finding_number,finding.id
|
ORDER BY finding.finding_number,finding.id
|
||||||
`, [actId]) as Array<Record<string, unknown>>;
|
`, [actId]) as Array<Record<string, unknown>>;
|
||||||
return {
|
return { schemaVersion: CLOSURE_SCHEMA_VERSION, lockedAt: lockedAt.toISOString(), act: act.act, context, responsible, inventories, findings };
|
||||||
schemaVersion: CLOSURE_SCHEMA_VERSION,
|
|
||||||
lockedAt: lockedAt.toISOString(),
|
|
||||||
act: act.act,
|
|
||||||
responsible,
|
|
||||||
inventories,
|
|
||||||
findings,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deadlinePolicy(manager: EntityManager): Promise<DeadlinePolicy> {
|
private async deadlinePolicy(manager: EntityManager): Promise<DeadlinePolicy> {
|
||||||
|
|||||||
@@ -41,9 +41,18 @@ interface FindingAssetView {
|
|||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
commonName: string | null;
|
commonName: string | null;
|
||||||
|
typeCode: string;
|
||||||
typeName: string;
|
typeName: string;
|
||||||
operatorCompany: FindingContextView | null;
|
operatorCompany: FindingContextView | null;
|
||||||
operationalArea: 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 = {
|
type FindingCatalogView = {
|
||||||
@@ -629,6 +638,13 @@ export class InspectionFindingsService {
|
|||||||
if (Object.keys(dto).length === 0) {
|
if (Object.keys(dto).length === 0) {
|
||||||
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
|
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
|
||||||
}
|
}
|
||||||
|
if (dto.companyResponse !== undefined || dto.companyResponseReceivedOn !== undefined
|
||||||
|
|| dto.companyCommittedCorrectionOn !== undefined) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INSPECTION_FINDING_COMPANY_RESPONSE_IN_REPORT',
|
||||||
|
message: 'La respuesta de la empresa se registra en el Informe relacionado, conservando este Hallazgo como antecedente.',
|
||||||
|
});
|
||||||
|
}
|
||||||
return this.dataSource.transaction(async (manager) => {
|
return this.dataSource.transaction(async (manager) => {
|
||||||
const finding = await this.lockFinding(manager, id);
|
const finding = await this.lockFinding(manager, id);
|
||||||
this.assertFindingOpen(finding);
|
this.assertFindingOpen(finding);
|
||||||
@@ -802,13 +818,34 @@ export class InspectionFindingsService {
|
|||||||
'code', asset.code,
|
'code', asset.code,
|
||||||
'name', asset.name,
|
'name', asset.name,
|
||||||
'commonName', asset.common_name,
|
'commonName', asset.common_name,
|
||||||
|
'typeCode', asset_type.code,
|
||||||
'typeName', asset_type.name,
|
'typeName', asset_type.name,
|
||||||
'operatorCompany', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
'operatorCompany', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
'id', company.id, 'code', company.code, 'name', company.name
|
'id', company.id, 'code', company.code, 'name', company.name
|
||||||
) END,
|
) END,
|
||||||
'operationalArea', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
'operationalArea', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
'id', area.id, 'code', area.code, 'name', area.name
|
'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,
|
) AS asset,
|
||||||
CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
'id', catalog.id,
|
'id', catalog.id,
|
||||||
@@ -854,6 +891,24 @@ export class InspectionFindingsService {
|
|||||||
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
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 company ON company.id = asset.operator_company_id
|
||||||
LEFT JOIN assets area ON area.id = asset.operational_area_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_catalog_items catalog ON catalog.id = finding.catalog_item_id
|
||||||
LEFT JOIN finding_categories category ON category.id = catalog.category_id
|
LEFT JOIN finding_categories category ON category.id = catalog.category_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsDateString, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class SetInspectionReportResponseDeadlineDto {
|
||||||
|
@IsDateString()
|
||||||
|
responseDueOn!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(1000)
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
@@ -1,214 +1,300 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import PDFDocument = require('pdfkit');
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> {
|
export interface ActPdfImage {
|
||||||
return value && typeof value === 'object' && !Array.isArray(value)
|
id: string;
|
||||||
? value as Record<string, unknown>
|
findingId?: string;
|
||||||
: {};
|
assetId?: string;
|
||||||
|
signerName?: string;
|
||||||
|
title?: string;
|
||||||
|
capturedAt?: string | Date | null;
|
||||||
|
sha256: string;
|
||||||
|
buffer: Buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActPdfContext {
|
||||||
|
companyName?: string | null;
|
||||||
|
departmentName?: string | null;
|
||||||
|
areaName?: string | null;
|
||||||
|
scopeName?: string | null;
|
||||||
|
scopeCode?: string | null;
|
||||||
|
scopeTypeName?: string | null;
|
||||||
|
scopeTypeCode?: string | null;
|
||||||
|
yacimientoName?: string | null;
|
||||||
|
yacimientoCode?: string | null;
|
||||||
|
leadInspectorName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||||
|
}
|
||||||
function asArray(value: unknown): Array<Record<string, unknown>> {
|
function asArray(value: unknown): Array<Record<string, unknown>> {
|
||||||
return Array.isArray(value) ? value.map(asRecord) : [];
|
return Array.isArray(value) ? value.map(asRecord) : [];
|
||||||
}
|
}
|
||||||
|
function text(value: unknown, fallback = ''): string {
|
||||||
function text(value: unknown, fallback = '-'): string {
|
return String(value ?? '').trim() || fallback;
|
||||||
const out = String(value ?? '').trim();
|
|
||||||
return out || fallback;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function date(value: unknown): string {
|
function date(value: unknown): string {
|
||||||
const parsed = new Date(String(value ?? ''));
|
const parsed = new Date(String(value ?? ''));
|
||||||
return Number.isFinite(parsed.getTime()) ? parsed.toLocaleDateString('es-AR') : '-';
|
return Number.isFinite(parsed.getTime())
|
||||||
|
? new Intl.DateTimeFormat('es-AR', {
|
||||||
|
timeZone: 'America/Argentina/Mendoza', day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||||
|
}).format(parsed)
|
||||||
|
: '-';
|
||||||
|
}
|
||||||
|
function fullName(value: Record<string, unknown>): string {
|
||||||
|
return `${text(value.firstName)} ${text(value.lastName)}`.trim() || text(value.username);
|
||||||
|
}
|
||||||
|
function isPlaceholder(value: unknown): boolean {
|
||||||
|
return text(value).startsWith('Acta de inspección en curso. Los Hallazgos');
|
||||||
|
}
|
||||||
|
function urgency(value: unknown): string {
|
||||||
|
return text(value) === 'URGENT' ? 'Urgente' : text(value) === 'NON_URGENT' ? 'No urgente' : '-';
|
||||||
|
}
|
||||||
|
function dayType(value: unknown): string {
|
||||||
|
return text(value) === 'BUSINESS' ? 'días hábiles' : text(value) === 'CALENDAR' ? 'días corridos' : '';
|
||||||
|
}
|
||||||
|
function deadlineBasis(value: unknown): string {
|
||||||
|
return text(value) === 'ACT_DATE' ? 'Desde la fecha del Acta' : text(value) === 'GEDO_DATE' ? 'Desde la notificación formal' : '';
|
||||||
|
}
|
||||||
|
function manifestation(value: unknown): string {
|
||||||
|
return text(value) === 'CONFORMITY' ? 'Firma sin disconformidad' : text(value) === 'DISSENT' ? 'Firma en disconformidad' : '';
|
||||||
|
}
|
||||||
|
function signatureStatus(value: unknown): string {
|
||||||
|
return text(value) === 'SIGNED' ? 'Firmó' : text(value) === 'REFUSED' ? 'Se negó a firmar' : text(value) === 'ABSENT' ? 'Ausente' : 'No firmó';
|
||||||
}
|
}
|
||||||
|
|
||||||
function clean(value: string): string {
|
// %PDF-1.4 is the document-version contract for consolidated Actas.
|
||||||
return value
|
export async function buildInspectionActPdf(
|
||||||
.normalize('NFKD')
|
snapshot: Record<string, unknown>,
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
images: ActPdfImage[] = [],
|
||||||
.replace(/[–—]/g, '-')
|
fallbackContext: ActPdfContext = {},
|
||||||
.replace(/[“”]/g, '"')
|
): Promise<{ buffer: Buffer; sha256: string }> {
|
||||||
.replace(/[‘’]/g, "'")
|
|
||||||
.replace(/[^\x20-\xFF]/g, '?');
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapePdf(value: string): string {
|
|
||||||
return clean(value).replaceAll('\\', '\\\\').replaceAll('(', '\\(').replaceAll(')', '\\)');
|
|
||||||
}
|
|
||||||
|
|
||||||
function wrap(value: string, max = 92): string[] {
|
|
||||||
const words = clean(value).split(/\s+/).filter(Boolean);
|
|
||||||
const out: string[] = [];
|
|
||||||
let line = '';
|
|
||||||
for (const word of words) {
|
|
||||||
const next = line ? `${line} ${word}` : word;
|
|
||||||
if (next.length > max && line) {
|
|
||||||
out.push(line);
|
|
||||||
line = word;
|
|
||||||
} else {
|
|
||||||
line = next;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (line) out.push(line);
|
|
||||||
return out.length ? out : ['-'];
|
|
||||||
}
|
|
||||||
|
|
||||||
function lockedSnapshot(snapshot: Record<string, unknown>): {
|
|
||||||
locked: Record<string, unknown>;
|
|
||||||
signatures: Array<Record<string, unknown>>;
|
|
||||||
finalSha256: unknown;
|
|
||||||
} {
|
|
||||||
const sealed = asRecord(snapshot);
|
const sealed = asRecord(snapshot);
|
||||||
const locked = asRecord(sealed.lockedSnapshot ?? sealed.preparedSnapshot);
|
const locked = asRecord(sealed.lockedSnapshot ?? sealed.preparedSnapshot);
|
||||||
return {
|
|
||||||
locked,
|
|
||||||
signatures: asArray(sealed.signatures),
|
|
||||||
finalSha256: sealed.finalSha256 ?? sealed.lockedSha256 ?? sealed.preparedSha256,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function urgencyLabel(value: unknown): string {
|
|
||||||
return text(value, '') === 'URGENT' ? 'Urgente' : 'No urgente';
|
|
||||||
}
|
|
||||||
|
|
||||||
function dayTypeLabel(value: unknown): string {
|
|
||||||
return text(value, '') === 'CALENDAR' ? 'dias corridos' : 'dias habiles';
|
|
||||||
}
|
|
||||||
|
|
||||||
function lines(snapshot: Record<string, unknown>): string[] {
|
|
||||||
const source = lockedSnapshot(snapshot);
|
|
||||||
const locked = source.locked;
|
|
||||||
const act = asRecord(locked.act);
|
const act = asRecord(locked.act);
|
||||||
const inspection = asRecord(act.inspection ?? asRecord(act).visit);
|
const inspection = asRecord(act.inspection ?? act.visit);
|
||||||
|
const context = asRecord(locked.context);
|
||||||
|
const company = asRecord(context.company);
|
||||||
|
const department = asRecord(context.department);
|
||||||
|
const area = asRecord(context.area);
|
||||||
|
const scope = asRecord(context.scope);
|
||||||
|
const leadInspector = asRecord(context.leadInspector);
|
||||||
|
const inspectors = asArray(context.inspectors);
|
||||||
const responsible = asRecord(locked.responsible);
|
const responsible = asRecord(locked.responsible);
|
||||||
const inventories = asArray(locked.inventories ?? locked.assets);
|
const inventories = asArray(locked.inventories);
|
||||||
const findings = asArray(locked.findings);
|
const findings = asArray(locked.findings);
|
||||||
const signatures = source.signatures;
|
const signatures = asArray(sealed.signatures);
|
||||||
const companySignature = signatures.find((item) => text(item.signerType, '') === 'COMPANY_RESPONSIBLE');
|
const seal = asRecord(sealed.seal);
|
||||||
const inspectorSignatures = signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR');
|
const hash = text(sealed.finalSha256 ?? sealed.lockedSha256);
|
||||||
|
const logo = resolve(process.cwd(), 'assets/logo-mendoza.png');
|
||||||
|
|
||||||
const deadlineText = act.deadlineAt
|
const doc = new PDFDocument({ size: 'A4', pdfVersion: '1.4', margins: { top: 146, bottom: 80, left: 54, right: 54 }, compress: true });
|
||||||
? `${date(act.deadlineAt)} (${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)})`
|
doc.registerFont('body', resolve(process.cwd(), 'assets/fonts/DejaVuSans.ttf'));
|
||||||
: act.deadlineBasis === 'GEDO_DATE'
|
doc.registerFont('body-bold', resolve(process.cwd(), 'assets/fonts/DejaVuSans-Bold.ttf'));
|
||||||
? `${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)} desde fecha GEDO`
|
const chunks: Buffer[] = [];
|
||||||
: '-';
|
doc.on('data', (part: Buffer) => chunks.push(part));
|
||||||
|
const done = new Promise<Buffer>((complete, reject) => { doc.on('end', () => complete(Buffer.concat(chunks))); doc.on('error', reject); });
|
||||||
|
const blue = '#162D69';
|
||||||
|
const ink = '#202939';
|
||||||
|
const muted = '#5A667C';
|
||||||
|
|
||||||
const out: string[] = [
|
const header = () => {
|
||||||
'ACTA DE INSPECCION',
|
doc.font('body-bold').fillColor(blue).fontSize(11).text('MINISTERIO DE ENERGÍA Y AMBIENTE', 54, 42);
|
||||||
'',
|
doc.text('DIRECCIÓN DE HIDROCARBUROS', 54, 58);
|
||||||
`Acta: ${text(act.code)}`,
|
if (existsSync(logo)) doc.image(logo, 474, 32, { fit: [62, 82] });
|
||||||
`Inspeccion: ${text(inspection.code)}`,
|
doc.moveTo(54, 121).lineTo(540, 121).strokeColor('#BAC5DA').stroke();
|
||||||
`Fecha: ${date(act.occurredAt)}`,
|
doc.y = 146;
|
||||||
`Urgencia: ${urgencyLabel(act.urgency)}`,
|
};
|
||||||
`Plazo: ${deadlineText}`,
|
doc.on('pageAdded', header);
|
||||||
`Representante de la empresa: ${text(responsible.fullName)}`,
|
header();
|
||||||
`DNI: ${text(responsible.documentNumber)}`,
|
|
||||||
`Cargo / funcion: ${text(responsible.position)}`,
|
|
||||||
`Email: ${text(responsible.email)}`,
|
|
||||||
'',
|
|
||||||
'RESUMEN',
|
|
||||||
...wrap(text(act.summary)),
|
|
||||||
'',
|
|
||||||
'OBSERVACIONES',
|
|
||||||
...wrap(text(act.observations)),
|
|
||||||
'',
|
|
||||||
'INVENTARIO INSPECCIONADO',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!inventories.length) out.push('-');
|
const need = (height: number) => { if (doc.y + height > doc.page.height - 85) doc.addPage(); };
|
||||||
for (const item of inventories) {
|
const section = (label: string) => {
|
||||||
out.push(...wrap(`${text(item.code)} | ${text(item.name)} | ${text(item.typeName ?? item.typeCode)}`));
|
need(46);
|
||||||
|
doc.moveDown(0.9);
|
||||||
|
doc.font('body-bold').fillColor(blue).fontSize(12).text(label.toUpperCase());
|
||||||
|
doc.moveTo(54, doc.y + 3).lineTo(540, doc.y + 3).strokeColor('#D5DCE8').stroke();
|
||||||
|
doc.moveDown(0.55);
|
||||||
|
};
|
||||||
|
const body = (value: unknown) => {
|
||||||
|
need(24);
|
||||||
|
doc.font('body').fillColor(ink).fontSize(10.2).text(text(value, '-'), { lineGap: 3 });
|
||||||
|
doc.moveDown(0.4);
|
||||||
|
};
|
||||||
|
const label = (name: string, value: unknown, allowEmpty = false) => {
|
||||||
|
const rendered = text(value);
|
||||||
|
if (!rendered && !allowEmpty) return;
|
||||||
|
need(24);
|
||||||
|
doc.font('body-bold').fillColor(ink).fontSize(9.8).text(`${name}: `, { continued: true });
|
||||||
|
doc.font('body').text(rendered || '-');
|
||||||
|
doc.moveDown(0.28);
|
||||||
|
};
|
||||||
|
const subheading = (value: string) => {
|
||||||
|
need(32);
|
||||||
|
doc.font('body-bold').fillColor(blue).fontSize(10.8).text(value);
|
||||||
|
doc.moveDown(0.25);
|
||||||
|
};
|
||||||
|
const image = (entry: ActPdfImage, caption: string) => {
|
||||||
|
need(235);
|
||||||
|
const y = doc.y;
|
||||||
|
doc.image(entry.buffer, 58, y, { fit: [470, 190] });
|
||||||
|
doc.y = y + 195;
|
||||||
|
doc.font('body').fontSize(8).fillColor(muted).text(`${caption} · SHA-256 ${entry.sha256}`, 58, doc.y, { width: 475 });
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
};
|
||||||
|
|
||||||
|
doc.font('body-bold').fillColor(ink).fontSize(18.5).text(`ACTA DE INSPECCIÓN ${text(act.code)}`);
|
||||||
|
doc.moveDown(0.15);
|
||||||
|
doc.font('body').fillColor(muted).fontSize(9).text('Documento consolidado de actuación inspectiva');
|
||||||
|
|
||||||
|
section('1. Identificación del Acta');
|
||||||
|
label('Código del Acta', act.code);
|
||||||
|
if (act.actNumber || act.actYear) label('Número / Año', `${text(act.actNumber, '-')} / ${text(act.actYear, '-')}`);
|
||||||
|
label('Inspección', inspection.code);
|
||||||
|
label('Fecha y hora de actuación', date(act.occurredAt));
|
||||||
|
label('Estado documental', 'Sellada');
|
||||||
|
label('Versión de esquema', locked.schemaVersion ?? sealed.schemaVersion);
|
||||||
|
|
||||||
|
section('2. Contexto territorial y operativo');
|
||||||
|
label('Empresa / Operadora', company.name ?? fallbackContext.companyName);
|
||||||
|
label('Departamento', department.name ?? fallbackContext.departmentName);
|
||||||
|
label('Área', area.name ?? fallbackContext.areaName);
|
||||||
|
const pathYacimientos = inventories.flatMap((inventory) => asArray(inventory.path))
|
||||||
|
.filter((node) => text(node.typeCode).toLowerCase() === 'yacimiento');
|
||||||
|
const scopeTypeCode = text(scope.typeCode ?? fallbackContext.scopeTypeCode).toLowerCase();
|
||||||
|
const yacimientoNames = Array.from(new Set([
|
||||||
|
...(scopeTypeCode === 'yacimiento' ? [text(scope.name ?? fallbackContext.scopeName)] : []),
|
||||||
|
...pathYacimientos.map((node) => text(node.name)),
|
||||||
|
text(fallbackContext.yacimientoName),
|
||||||
|
].filter(Boolean)));
|
||||||
|
const yacimientoCodes = Array.from(new Set([
|
||||||
|
...(scopeTypeCode === 'yacimiento' ? [text(scope.code ?? fallbackContext.scopeCode)] : []),
|
||||||
|
...pathYacimientos.map((node) => text(node.code)),
|
||||||
|
text(fallbackContext.yacimientoCode),
|
||||||
|
].filter(Boolean)));
|
||||||
|
if (yacimientoNames.length) label(yacimientoNames.length > 1 ? 'Yacimientos' : 'Yacimiento', yacimientoNames.join(', '));
|
||||||
|
if (yacimientoCodes.length) label(yacimientoCodes.length > 1 ? 'Códigos de yacimiento' : 'Código de yacimiento', yacimientoCodes.join(', '));
|
||||||
|
if (scopeTypeCode && scopeTypeCode !== 'yacimiento') {
|
||||||
|
const scopeLabel = `${text(scope.typeName ?? fallbackContext.scopeTypeName)} - ${text(scope.name ?? fallbackContext.scopeName)}${text(scope.code ?? fallbackContext.scopeCode) ? ` [${text(scope.code ?? fallbackContext.scopeCode)}]` : ''}`;
|
||||||
|
label('Alcance de la inspección', scopeLabel);
|
||||||
|
}
|
||||||
|
label('Inicio efectivo de la inspección', date(inspection.actualStartedAt));
|
||||||
|
|
||||||
|
section('3. Intervinientes');
|
||||||
|
const leadName = fullName(leadInspector) || fallbackContext.leadInspectorName || '';
|
||||||
|
label('Inspector/a responsable', leadName);
|
||||||
|
const otherInspectors = inspectors.map(fullName).filter((name) => name && name !== leadName);
|
||||||
|
if (otherInspectors.length) label('Otros inspectores actuantes', otherInspectors.join(', '));
|
||||||
|
label('Situación del representante', text(responsible.attendanceStatus) === 'ABSENT' ? 'Ausente' : 'Presente');
|
||||||
|
label('Representante de la empresa', responsible.fullName);
|
||||||
|
const document = [text(responsible.documentType), text(responsible.documentNumber)].filter(Boolean).join(' ');
|
||||||
|
label('Documento', document);
|
||||||
|
label('Cargo o función', responsible.position);
|
||||||
|
label('Correo electrónico', responsible.email);
|
||||||
|
label('Teléfono', responsible.phone);
|
||||||
|
if (text(responsible.attendanceStatus) === 'ABSENT') label('Motivo de ausencia', responsible.absenceReason);
|
||||||
|
|
||||||
|
section('4. Datos del Acta');
|
||||||
|
label('Objeto / Denominación', act.title);
|
||||||
|
label('Urgencia', urgency(act.urgency));
|
||||||
|
if (act.deadlineDays != null) {
|
||||||
|
const duration = `${text(act.deadlineDays)} ${dayType(act.deadlineDayType)}`.trim();
|
||||||
|
label('Plazo', duration);
|
||||||
|
label('Cómputo del plazo', deadlineBasis(act.deadlineBasis));
|
||||||
|
if (act.deadlineBaseAt) label('Fecha base', date(act.deadlineBaseAt));
|
||||||
|
if (act.deadlineAt) label('Vencimiento', date(act.deadlineAt));
|
||||||
|
}
|
||||||
|
subheading('Descripción de lo actuado');
|
||||||
|
if (text(act.summary) && !isPlaceholder(act.summary)) body(act.summary);
|
||||||
|
else body(`Se realizó la inspección ${text(inspection.code)}. El contenido constatado se detalla en los hallazgos registrados a continuación.`);
|
||||||
|
if (text(act.observations)) {
|
||||||
|
subheading('Observaciones generales');
|
||||||
|
body(act.observations);
|
||||||
}
|
}
|
||||||
|
|
||||||
out.push('', 'HALLAZGOS');
|
section('5. Hallazgos');
|
||||||
if (!findings.length) out.push('Sin hallazgos registrados.');
|
if (!findings.length) body('No se registraron hallazgos en esta Acta.');
|
||||||
for (const item of findings) {
|
for (const finding of findings) {
|
||||||
const recurrence = item.isRecurrence
|
const inventory = inventories.find((item) => text(item.id) === text(finding.assetId)) ?? {};
|
||||||
? ` | REINCIDENCIA${item.recurrenceOfFindingId ? ` de ${text(item.recurrenceOfFindingCode ?? item.recurrenceOfFindingId)}` : ''}`
|
const catalog = asRecord(finding.catalog);
|
||||||
: '';
|
const recurrence = asRecord(finding.recurrence);
|
||||||
out.push(...wrap(`${text(item.code)} | ${text(item.title)}${recurrence}`));
|
const path = asArray(inventory.path);
|
||||||
out.push(...wrap(`Descripcion: ${text(item.description)}`));
|
const route = path.map((node) => `${text(node.typeName)}: ${text(node.name)}${text(node.code) ? ` [${text(node.code)}]` : ''}`).filter(Boolean).join(' > ');
|
||||||
if (item.legalBasis) out.push(...wrap(`Base legal: ${text(item.legalBasis)}`));
|
const number = text(finding.findingNumber, '?');
|
||||||
|
need(100);
|
||||||
|
doc.font('body-bold').fillColor(blue).fontSize(12).text(`HALLAZGO N° ${number} · ${text(finding.code)}`);
|
||||||
|
doc.moveDown(0.2);
|
||||||
|
label('Elemento afectado', `${text(inventory.typeName)} - ${text(inventory.name)}${text(inventory.code) ? ` [${text(inventory.code)}]` : ''}`);
|
||||||
|
if (text(inventory.installationTypeName)) label('Tipo de instalación', inventory.installationTypeName);
|
||||||
|
if (route) label('Ubicación / Ruta jerárquica', route);
|
||||||
|
label('Denominación del hallazgo', finding.title);
|
||||||
|
subheading('Qué se constató');
|
||||||
|
body(finding.description);
|
||||||
|
if (text(catalog.id)) {
|
||||||
|
const source = [text(catalog.categoryName), text(catalog.title), text(catalog.code), catalog.sourceNumber ? `Ítem ${text(catalog.sourceNumber)}` : ''].filter(Boolean).join(' · ');
|
||||||
|
label('Referencia de catálogo', source);
|
||||||
|
} else {
|
||||||
|
label('Referencia de catálogo', 'OTROS / hallazgo cargado en campo');
|
||||||
|
}
|
||||||
|
if (text(finding.glossary)) label('Criterio / Referencia técnica', finding.glossary);
|
||||||
|
if (text(finding.legalBasis)) label('Normativa / Base legal', finding.legalBasis);
|
||||||
|
if (finding.severity != null) label('Gravedad', `${text(finding.severity)}/10`);
|
||||||
|
if (finding.isRecurrence) {
|
||||||
|
label('Reincidencia', 'Sí');
|
||||||
|
if (text(recurrence.code) || text(recurrence.title)) label('Antecedente relacionado', `${text(recurrence.code)}${text(recurrence.title) ? ` - ${text(recurrence.title)}` : ''}`);
|
||||||
|
} else {
|
||||||
|
label('Reincidencia', 'No');
|
||||||
|
}
|
||||||
|
const photos = images.filter((item) => item.findingId === text(finding.id));
|
||||||
|
if (photos.length) {
|
||||||
|
subheading(`Evidencia fotográfica (${photos.length})`);
|
||||||
|
for (const photo of photos) image(photo, `Fotografía del hallazgo ${text(finding.code)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`);
|
||||||
|
} else {
|
||||||
|
label('Evidencia fotográfica', 'Sin fotografías asociadas');
|
||||||
|
}
|
||||||
|
doc.moveDown(0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
out.push('', 'FIRMAS Y CONSTANCIAS');
|
section('6. Firmas y manifestaciones');
|
||||||
if (!inspectorSignatures.length) out.push('Firma de inspector: pendiente.');
|
for (const signature of signatures) {
|
||||||
for (const signature of inspectorSignatures) {
|
const name = text(signature.signerName);
|
||||||
out.push(...wrap(`Inspector: ${text(signature.signerName)} | ${text(signature.status)} | ${date(signature.signedAt ?? signature.createdAt)}`));
|
const inspector = text(signature.signerType) === 'INSPECTOR';
|
||||||
}
|
const role = inspector ? 'Inspector/a' : 'Representante de la empresa';
|
||||||
if (!companySignature) {
|
subheading(`${role}: ${name}`);
|
||||||
out.push('Manifestacion de empresa: pendiente.');
|
const signedDocument = [text(signature.documentType), text(signature.documentNumber)].filter(Boolean).join(' ');
|
||||||
} else if (text(companySignature.status, '') === 'SIGNED') {
|
if (signedDocument) label('Documento', signedDocument);
|
||||||
const manifestation = text(companySignature.companyManifestation, 'CONFORMITY');
|
label('Cargo o función', signature.position);
|
||||||
out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disconformidad' : 'Empresa: firma en conformidad');
|
label('Resultado', signatureStatus(signature.status));
|
||||||
if (manifestation === 'DISSENT') out.push(...wrap(text(companySignature.companyStatement)));
|
if (!inspector && text(signature.companyManifestation)) label('Manifestación', manifestation(signature.companyManifestation));
|
||||||
} else {
|
if (signature.companyManifestation === 'DISSENT') label('Fundamento de la disconformidad', signature.companyStatement);
|
||||||
out.push(...wrap(`Empresa: ${text(companySignature.status)} - ${text(companySignature.reason)}`));
|
if (text(signature.status) === 'REFUSED' || text(signature.status) === 'ABSENT') label('Motivo', signature.reason);
|
||||||
|
label('Fecha y hora de la constancia', date(signature.signedAt ?? signature.createdAt));
|
||||||
|
if (text(signature.source)) label('Origen de la constancia', text(signature.source) === 'ANDROID' ? 'Aplicación móvil' : 'Dashboard web');
|
||||||
|
const signatureImage = images.find((item) => item.signerName === name && item.sha256 === text(signature.imageSha256));
|
||||||
|
if (signatureImage) {
|
||||||
|
need(100);
|
||||||
|
const y = doc.y;
|
||||||
|
doc.image(signatureImage.buffer, 60, y, { fit: [230, 60] });
|
||||||
|
doc.y = y + 66;
|
||||||
|
}
|
||||||
|
doc.moveDown(0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
out.push(
|
section('7. Integridad y cierre');
|
||||||
'',
|
label('Contenido bloqueado', date(locked.lockedAt));
|
||||||
'INTEGRIDAD',
|
label('Sellado en servidor', date(seal.serverSealedAt));
|
||||||
`Hash del Acta sellada: ${text(source.finalSha256)}`,
|
label('Fecha informada por dispositivo', date(seal.deviceSealedAt));
|
||||||
`Hash del contenido bloqueado: ${text(snapshot.lockedSha256 ?? snapshot.preparedSha256)}`,
|
label('Modo de cierre', seal.uploadMode);
|
||||||
);
|
body(`SHA-256 del cierre: ${hash}`);
|
||||||
return out;
|
body(`SHA-256 del contenido bloqueado: ${text(sealed.lockedSha256)}`);
|
||||||
}
|
need(28);
|
||||||
|
doc.fontSize(8).fillColor(muted).text(`Acta ${text(act.code)} · plantilla consolidada v3 · ${date(seal.serverSealedAt)}`, 54, doc.y);
|
||||||
|
|
||||||
function objectBuffer(id: number, body: Buffer | string): Buffer {
|
doc.end();
|
||||||
const data = Buffer.isBuffer(body) ? body : Buffer.from(body, 'latin1');
|
const buffer = await done;
|
||||||
return Buffer.concat([
|
|
||||||
Buffer.from(`${id} 0 obj\n`, 'ascii'),
|
|
||||||
data,
|
|
||||||
Buffer.from('\nendobj\n', 'ascii'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildInspectionActPdf(snapshot: Record<string, unknown>): { buffer: Buffer; sha256: string } {
|
|
||||||
const all = lines(snapshot);
|
|
||||||
const chunks: Array<string[]> = [];
|
|
||||||
for (let index = 0; index < all.length; index += 56) chunks.push(all.slice(index, index + 56));
|
|
||||||
if (!chunks.length) chunks.push(['ACTA DE INSPECCION']);
|
|
||||||
|
|
||||||
const pageCount = chunks.length;
|
|
||||||
const pageIds = Array.from({ length: pageCount }, (_, index) => 4 + index * 2);
|
|
||||||
const contentIds = Array.from({ length: pageCount }, (_, index) => 5 + index * 2);
|
|
||||||
const objects: Buffer[] = [];
|
|
||||||
objects.push(objectBuffer(1, '<< /Type /Catalog /Pages 2 0 R >>'));
|
|
||||||
objects.push(objectBuffer(2, `<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] >>`));
|
|
||||||
objects.push(objectBuffer(3, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'));
|
|
||||||
|
|
||||||
chunks.forEach((chunk, index) => {
|
|
||||||
const content = chunk
|
|
||||||
.map((line, lineIndex) => `${lineIndex === 0 ? '' : 'T* '}(${escapePdf(line)}) Tj`)
|
|
||||||
.join('\n');
|
|
||||||
const stream = Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`, 'latin1');
|
|
||||||
objects.push(objectBuffer(
|
|
||||||
pageIds[index]!,
|
|
||||||
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentIds[index]} 0 R >>`,
|
|
||||||
));
|
|
||||||
objects.push(objectBuffer(
|
|
||||||
contentIds[index]!,
|
|
||||||
Buffer.concat([
|
|
||||||
Buffer.from(`<< /Length ${stream.length} >>\nstream\n`, 'ascii'),
|
|
||||||
stream,
|
|
||||||
Buffer.from('\nendstream', 'ascii'),
|
|
||||||
]),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
const header = Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary');
|
|
||||||
const offsets: number[] = [0];
|
|
||||||
let position = header.length;
|
|
||||||
for (const object of objects) {
|
|
||||||
offsets.push(position);
|
|
||||||
position += object.length;
|
|
||||||
}
|
|
||||||
const xrefOffset = position;
|
|
||||||
const xref = [
|
|
||||||
`xref\n0 ${objects.length + 1}\n`,
|
|
||||||
'0000000000 65535 f \n',
|
|
||||||
...objects.map((_, index) => `${String(offsets[index + 1]).padStart(10, '0')} 00000 n \n`),
|
|
||||||
].join('');
|
|
||||||
const trailer = `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
|
||||||
const buffer = Buffer.concat([header, ...objects, Buffer.from(xref + trailer, 'ascii')]);
|
|
||||||
return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') };
|
return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import { isAbsolute, parse, resolve } from 'node:path';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { buildInspectionActPdf } from './inspection-act-pdf-builder';
|
import { buildInspectionActPdf, type ActPdfContext, type ActPdfImage } from './inspection-act-pdf-builder';
|
||||||
|
import { renderableInspectionImage } from './inspection-document-images';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InspectionActPdfService {
|
export class InspectionActPdfService {
|
||||||
private readonly root: string;
|
private readonly root: string;
|
||||||
|
private readonly evidenceRoot: string;
|
||||||
|
private readonly assetRoot: string;
|
||||||
|
private readonly signatureRoot: string;
|
||||||
|
|
||||||
constructor(private readonly dataSource: DataSource, config: ConfigService) {
|
constructor(private readonly dataSource: DataSource, config: ConfigService) {
|
||||||
const configured = config.get<string>('INSPECTION_ACT_PDF_ROOT')
|
const configured = config.get<string>('INSPECTION_ACT_PDF_ROOT')
|
||||||
@@ -16,6 +20,9 @@ export class InspectionActPdfService {
|
|||||||
if (!isAbsolute(configured)) throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path');
|
if (!isAbsolute(configured)) throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path');
|
||||||
this.root = resolve(configured);
|
this.root = resolve(configured);
|
||||||
if (this.root === parse(this.root).root) throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root');
|
if (this.root === parse(this.root).root) throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root');
|
||||||
|
this.evidenceRoot = resolve(config.get<string>('INSPECTION_EVIDENCE_ROOT') ?? '/app/storage/asset-media/inspection-findings');
|
||||||
|
this.assetRoot = resolve(config.get<string>('ASSET_MEDIA_ROOT') ?? '/app/storage/asset-media');
|
||||||
|
this.signatureRoot = resolve(config.get<string>('INSPECTION_SIGNATURE_ROOT') ?? '/app/storage/asset-media/inspection-signatures');
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensure(actId: string): Promise<void> {
|
async ensure(actId: string): Promise<void> {
|
||||||
@@ -59,7 +66,7 @@ export class InspectionActPdfService {
|
|||||||
`, [actId]);
|
`, [actId]);
|
||||||
try {
|
try {
|
||||||
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 };
|
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 };
|
||||||
const built = buildInspectionActPdf(snapshot);
|
const built = await buildInspectionActPdf(snapshot, await this.fieldImages(actId, false), await this.actContext(actId));
|
||||||
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||||
const storedName = `${actId}.pdf`;
|
const storedName = `${actId}.pdf`;
|
||||||
const originalName = `${row.code}.pdf`;
|
const originalName = `${row.code}.pdf`;
|
||||||
@@ -82,6 +89,154 @@ export class InspectionActPdfService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The original sealed PDF remains immutable for historic delivery and audit.
|
||||||
|
// A consolidated presentation is stored separately and frozen at its first generation.
|
||||||
|
async consolidatedContent(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
|
||||||
|
const [existing] = await this.dataSource.query(`
|
||||||
|
SELECT stored_name AS "storedName",original_name AS "originalName",
|
||||||
|
size_bytes AS "sizeBytes",sha256
|
||||||
|
FROM inspection_act_consolidated_pdf_revisions
|
||||||
|
WHERE act_id=$1 AND template_version=3
|
||||||
|
`, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
||||||
|
if (existing) {
|
||||||
|
const buffer = await this.verifiedImage(this.root, existing);
|
||||||
|
return { buffer, originalName: existing.originalName, mimeType: 'application/pdf' };
|
||||||
|
}
|
||||||
|
const [row] = await this.dataSource.query(`
|
||||||
|
SELECT act.code,act.closure_sha256 AS "closureSha256",closure.final_snapshot AS "finalSnapshot"
|
||||||
|
FROM inspection_acts act
|
||||||
|
JOIN inspection_act_closures closure ON closure.act_id=act.id
|
||||||
|
WHERE act.id=$1 AND act.status='SEALED'
|
||||||
|
`, [actId]) as Array<{ code: string; closureSha256: string; finalSnapshot: Record<string, unknown> }>;
|
||||||
|
if (!row) throw new NotFoundException({
|
||||||
|
code: 'INSPECTION_ACT_NOT_SEALED',
|
||||||
|
message: 'El Acta debe estar firmada y sellada para generar el documento consolidado',
|
||||||
|
});
|
||||||
|
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 };
|
||||||
|
const built = await buildInspectionActPdf(snapshot, await this.fieldImages(actId, false), await this.actContext(actId));
|
||||||
|
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||||
|
const storedName = `${actId}-consolidado-${built.sha256.slice(0, 24)}.pdf`;
|
||||||
|
const originalName = `${row.code}-consolidada.pdf`;
|
||||||
|
await writeFile(resolve(this.root, storedName), built.buffer, { flag: 'wx', mode: 0o600 }).catch(async (error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code !== 'EEXIST') throw error;
|
||||||
|
const previous = await this.verifiedImage(this.root, {
|
||||||
|
storedName, sizeBytes: built.buffer.length, sha256: built.sha256,
|
||||||
|
});
|
||||||
|
if (!previous.equals(built.buffer)) throw this.storageError();
|
||||||
|
});
|
||||||
|
await this.dataSource.query(`
|
||||||
|
INSERT INTO inspection_act_consolidated_pdf_revisions(act_id,template_version,stored_name,original_name,size_bytes,sha256)
|
||||||
|
VALUES($1,3,$2,$3,$4,$5) ON CONFLICT (act_id,template_version) DO NOTHING
|
||||||
|
`, [actId, storedName, originalName, built.buffer.length, built.sha256]);
|
||||||
|
const [saved] = await this.dataSource.query(`
|
||||||
|
SELECT stored_name AS "storedName",original_name AS "originalName",
|
||||||
|
size_bytes AS "sizeBytes",sha256
|
||||||
|
FROM inspection_act_consolidated_pdf_revisions WHERE act_id=$1 AND template_version=3
|
||||||
|
`, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
||||||
|
if (!saved) throw this.storageError();
|
||||||
|
return {
|
||||||
|
buffer: await this.verifiedImage(this.root, saved),
|
||||||
|
originalName: saved.originalName, mimeType: 'application/pdf',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async consolidatedRevisionContent(actId: string, version: number): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
|
||||||
|
if (![1, 2, 3].includes(version)) throw new NotFoundException('Versión documental inexistente');
|
||||||
|
const [row] = await this.dataSource.query(`
|
||||||
|
SELECT stored_name AS "storedName",original_name AS "originalName",
|
||||||
|
size_bytes AS "sizeBytes",sha256
|
||||||
|
FROM inspection_act_consolidated_pdf_revisions
|
||||||
|
WHERE act_id=$1 AND template_version=$2
|
||||||
|
`, [actId, version]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
||||||
|
if (!row) throw new NotFoundException('Versión documental inexistente');
|
||||||
|
return { buffer: await this.verifiedImage(this.root, row), originalName: row.originalName, mimeType: 'application/pdf' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async actContext(actId: string): Promise<ActPdfContext> {
|
||||||
|
const [row] = await this.dataSource.query(`
|
||||||
|
SELECT company.name AS "companyName",department.name AS "departmentName",area.name AS "areaName",
|
||||||
|
scope.name AS "scopeName",scope.code AS "scopeCode",scope_type.name AS "scopeTypeName",scope_type.code AS "scopeTypeCode",
|
||||||
|
COALESCE(CASE WHEN lower(scope_type.code)='yacimiento' THEN scope.name END,yacimiento_from_findings.name) AS "yacimientoName",
|
||||||
|
COALESCE(CASE WHEN lower(scope_type.code)='yacimiento' THEN scope.code END,yacimiento_from_findings.code) AS "yacimientoCode",
|
||||||
|
btrim(concat_ws(' ',lead.first_name,lead.last_name)) AS "leadInspectorName"
|
||||||
|
FROM inspection_acts act
|
||||||
|
JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||||
|
LEFT JOIN assets company ON company.id=visit.operator_company_id
|
||||||
|
LEFT JOIN assets area ON area.id=visit.operational_area_id
|
||||||
|
LEFT JOIN assets department ON department.id=area.parent_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
|
||||||
|
LEFT JOIN users lead ON lead.id=visit.lead_inspector_user_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT asset.id,asset.parent_id,asset.code,asset.name,asset.asset_type_id
|
||||||
|
FROM inspection_findings finding JOIN assets asset ON asset.id=finding.asset_id
|
||||||
|
WHERE finding.act_id=act.id AND finding.status<>'VOIDED'
|
||||||
|
UNION
|
||||||
|
SELECT parent.id,parent.parent_id,parent.code,parent.name,parent.asset_type_id
|
||||||
|
FROM ancestors JOIN assets parent ON parent.id=ancestors.parent_id
|
||||||
|
)
|
||||||
|
SELECT string_agg(DISTINCT ancestors.name, ', ' ORDER BY ancestors.name) AS name,
|
||||||
|
string_agg(DISTINCT ancestors.code, ', ' ORDER BY ancestors.code) AS code
|
||||||
|
FROM ancestors JOIN asset_types ancestor_type ON ancestor_type.id=ancestors.asset_type_id
|
||||||
|
WHERE lower(ancestor_type.code)='yacimiento'
|
||||||
|
) yacimiento_from_findings ON true
|
||||||
|
WHERE act.id=$1
|
||||||
|
`, [actId]) as ActPdfContext[];
|
||||||
|
return row ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifiedImage(root: string, row: { storedName: string; sha256: string; sizeBytes: number }): Promise<Buffer> {
|
||||||
|
if (!/^[A-Za-z0-9_.-]+$/.test(row.storedName)) throw new Error('Ruta inválida de evidencia del Acta');
|
||||||
|
const path = resolve(root, row.storedName);
|
||||||
|
if (!path.startsWith(`${root}/`)) throw new Error('Ruta inválida de evidencia del Acta');
|
||||||
|
const file = await stat(path);
|
||||||
|
if (!file.isFile() || file.size !== Number(row.sizeBytes)) throw new Error('Evidencia incompleta del Acta');
|
||||||
|
const buffer = await readFile(path);
|
||||||
|
if (createHash('sha256').update(buffer).digest('hex') !== row.sha256) throw new Error('Hash de evidencia distinto del registrado');
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fieldImages(actId: string, includeAssetPhotos = true): Promise<ActPdfImage[]> {
|
||||||
|
type ImageRow = { id: string; findingId?: string; assetId?: string; signerName?: string; title?: string; capturedAt?: Date; storedName: string; sha256: string; sizeBytes: number };
|
||||||
|
const findings = await this.dataSource.query(`
|
||||||
|
SELECT evidence.id, finding.id AS "findingId", evidence.title,
|
||||||
|
evidence.captured_at AS "capturedAt", evidence.stored_name AS "storedName",
|
||||||
|
evidence.sha256, evidence.size_bytes AS "sizeBytes"
|
||||||
|
FROM inspection_findings finding
|
||||||
|
JOIN inspection_acts act ON act.id=finding.act_id
|
||||||
|
JOIN inspection_finding_evidence evidence ON evidence.finding_id=finding.id
|
||||||
|
WHERE act.id=$1 AND evidence.kind='PHOTO' AND evidence.purpose='OBSERVATION'
|
||||||
|
AND evidence.created_at<=act.locked_at
|
||||||
|
ORDER BY finding.finding_number,evidence.captured_at,evidence.id
|
||||||
|
`, [actId]) as ImageRow[];
|
||||||
|
const assets = includeAssetPhotos ? await this.dataSource.query(`
|
||||||
|
SELECT media.id,asset.id AS "assetId", asset.name AS title,
|
||||||
|
capture.device_captured_at AS "capturedAt",media.stored_name AS "storedName",
|
||||||
|
media.sha256,media.size_bytes AS "sizeBytes"
|
||||||
|
FROM inspection_acts act
|
||||||
|
JOIN inspection_act_assets link ON link.act_id=act.id AND link.included=true
|
||||||
|
JOIN assets asset ON asset.id=link.asset_id
|
||||||
|
JOIN asset_field_capture_events capture ON capture.visit_id=act.visit_id
|
||||||
|
AND capture.asset_id=asset.id AND capture.event_type='PHOTO'
|
||||||
|
JOIN asset_media media ON media.id=capture.media_id AND media.deleted_at IS NULL AND media.kind='PHOTO'
|
||||||
|
WHERE act.id=$1 AND capture.created_at<=act.locked_at
|
||||||
|
ORDER BY capture.device_captured_at,media.id
|
||||||
|
`, [actId]) as ImageRow[] : [];
|
||||||
|
const signatures = await this.dataSource.query(`
|
||||||
|
SELECT signature.id,signature.signer_name AS "signerName",signature.stored_name AS "storedName",
|
||||||
|
signature.image_sha256 AS sha256,signature.size_bytes AS "sizeBytes"
|
||||||
|
FROM inspection_act_signatures signature WHERE signature.act_id=$1
|
||||||
|
AND signature.status='SIGNED' AND signature.stored_name IS NOT NULL
|
||||||
|
ORDER BY signature.created_at,signature.id
|
||||||
|
`, [actId]) as ImageRow[];
|
||||||
|
const result: ActPdfImage[] = [];
|
||||||
|
for (const row of findings) result.push({ ...row, buffer: await renderableInspectionImage(await this.verifiedImage(this.evidenceRoot, row)) });
|
||||||
|
for (const row of assets) result.push({ ...row, buffer: await renderableInspectionImage(await this.verifiedImage(this.assetRoot, row)) });
|
||||||
|
for (const row of signatures) result.push({ ...row, buffer: await this.verifiedImage(this.signatureRoot, row) });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async content(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
|
async content(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
|
||||||
const [row] = await this.dataSource.query(`
|
const [row] = await this.dataSource.query(`
|
||||||
SELECT original_name AS "originalName",stored_name AS "storedName",
|
SELECT original_name AS "originalName",stored_name AS "storedName",
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const sharp = require('sharp') as typeof import('sharp').default;
|
||||||
|
|
||||||
|
// PDFKit and the Word image renderer accept JPEG/PNG. The source bytes and
|
||||||
|
// their recorded SHA-256 remain untouched; WebP is only converted for display.
|
||||||
|
export async function renderableInspectionImage(source: Buffer): Promise<Buffer> {
|
||||||
|
const isWebp = source.length >= 12
|
||||||
|
&& source.subarray(0, 4).toString('ascii') === 'RIFF'
|
||||||
|
&& source.subarray(8, 12).toString('ascii') === 'WEBP';
|
||||||
|
if (!isWebp) return source;
|
||||||
|
return sharp(source)
|
||||||
|
.resize({ width: 1600, height: 1200, fit: 'inside', withoutEnlargement: true })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
interface ZipEntry {
|
interface ZipEntry {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -13,6 +15,10 @@ interface ReportWordInput {
|
|||||||
frozenSnapshot: Record<string, unknown>;
|
frozenSnapshot: Record<string, unknown>;
|
||||||
executiveSummary?: string | null;
|
executiveSummary?: string | null;
|
||||||
reportDescription?: string | null;
|
reportDescription?: string | null;
|
||||||
|
companyName?: string | null;
|
||||||
|
areaName?: string | null;
|
||||||
|
scopeName?: string | null;
|
||||||
|
photos?: Array<{ id: string; findingId?: string; assetId?: string; title?: string; sha256: string; buffer: Buffer }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const crcTable = (() => {
|
const crcTable = (() => {
|
||||||
@@ -68,7 +74,7 @@ function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): st
|
|||||||
}
|
}
|
||||||
|
|
||||||
function labelValue(label: string, value: string): string {
|
function labelValue(label: string, value: string): string {
|
||||||
return `<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>${xmlEscape(label)}: </w:t></w:r><w:r><w:t xml:space="preserve">${xmlEscape(value)}</w:t></w:r></w:p>`;
|
return `<w:p><w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">${xmlEscape(label)}: </w:t></w:r><w:r><w:t xml:space="preserve">${xmlEscape(value)}</w:t></w:r></w:p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function table(headers: string[], rows: string[][]): string {
|
function table(headers: string[], rows: string[][]): string {
|
||||||
@@ -102,75 +108,93 @@ function deadline(act: Record<string, unknown>): string {
|
|||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
function documentXml(input: ReportWordInput): string {
|
function imageDimensions(buffer: Buffer): { width: number; height: number } {
|
||||||
|
if (buffer.subarray(0, 4).toString('hex') === '89504e47') return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
||||||
|
let offset = 2;
|
||||||
|
while (offset + 9 < buffer.length) {
|
||||||
|
if (buffer[offset] !== 0xff) break;
|
||||||
|
const marker = buffer[offset + 1]!;
|
||||||
|
if ([0xc0,0xc1,0xc2,0xc3,0xc5,0xc6,0xc7,0xc9,0xca,0xcb,0xcd,0xce,0xcf].includes(marker)) return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
|
||||||
|
const size = buffer.readUInt16BE(offset + 2);
|
||||||
|
if (size < 2) break;
|
||||||
|
offset += size + 2;
|
||||||
|
}
|
||||||
|
return { width: 800, height: 500 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawing(relationship: number, image: Buffer, name: string, maxWidth = 4572000, maxHeight = 2057400, right = false): string {
|
||||||
|
const dimensions = imageDimensions(image);
|
||||||
|
const scale = Math.min(maxWidth / dimensions.width, maxHeight / dimensions.height);
|
||||||
|
const cx = Math.round(dimensions.width * scale);
|
||||||
|
const cy = Math.round(dimensions.height * scale);
|
||||||
|
const alt = xmlEscape(name);
|
||||||
|
return `<w:p>${right ? '<w:pPr><w:jc w:val="right"/></w:pPr>' : ''}<w:r><w:drawing><wp:inline><wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="${relationship}" name="${alt}" descr="${alt}"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="${alt}"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rId${relationship}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentXml(input: ReportWordInput, logo: Buffer): string {
|
||||||
const snapshot = f4Snapshot(input);
|
const snapshot = f4Snapshot(input);
|
||||||
const findingRows = snapshot.findings.map((item) => [
|
const photos = input.photos ?? [];
|
||||||
text(item.code),
|
const authors = snapshot.signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR').map((item) => text(item.signerName)).join(', ');
|
||||||
text(item.title),
|
const legalBasis = [...new Set(snapshot.findings.map((item) => text(item.legalBasis, '')).filter(Boolean))];
|
||||||
text(item.description),
|
const quantity = `${snapshot.findings.length} ${snapshot.findings.length === 1 ? 'hallazgo' : 'hallazgos'}`;
|
||||||
item.isRecurrence ? `Sí${item.recurrenceOfFindingCode ? ` · ${text(item.recurrenceOfFindingCode)}` : ''}` : 'No',
|
const entries: string[] = [
|
||||||
item.severity == null ? '—' : `${text(item.severity)}/10`,
|
paragraph('MINISTERIO DE ENERGÍA Y AMBIENTE'),
|
||||||
]);
|
paragraph('DIRECCIÓN DE HIDROCARBUROS'),
|
||||||
const inventoryRows = snapshot.inventories.map((item) => [
|
drawing(2, logo, 'Identidad institucional Mendoza', 594360, 1005840, true),
|
||||||
text(item.code),
|
paragraph(`Mendoza, ${input.generatedAt.toLocaleDateString('es-AR')}`),
|
||||||
text(item.name),
|
paragraph(`INFORME TÉCNICO ${input.code}`, 'Title'),
|
||||||
text(item.typeName ?? item.typeCode),
|
paragraph('Sr. Director de Hidrocarburos'),
|
||||||
]);
|
labelValue('Referencia', text(input.scopeName ?? snapshot.inventories[0]?.name, 'Instalación inspeccionada')),
|
||||||
const signatureRows = snapshot.signatures.map((item) => [
|
labelValue('Inspector/a', authors || 'No consignado'),
|
||||||
text(item.signerType),
|
labelValue('Acta', text(snapshot.source.actCode ?? snapshot.act.code)),
|
||||||
text(item.signerName),
|
|
||||||
text(item.status),
|
|
||||||
text(item.companyManifestation, ''),
|
|
||||||
isoDate(item.signedAt ?? item.createdAt),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const executive = input.executiveSummary?.trim()
|
|
||||||
|| '[EDITAR] Incorporar aquí el resumen ejecutivo del informe.';
|
|
||||||
const description = input.reportDescription?.trim()
|
|
||||||
|| '[EDITAR] Incorporar aquí la descripción técnica, análisis y consideraciones del inspector.';
|
|
||||||
|
|
||||||
const body = [
|
|
||||||
paragraph('INFORME TÉCNICO DE INSPECCIÓN', 'Title'),
|
|
||||||
paragraph('Documento Word editable para revisión del Inspector antes de su incorporación a GEDO', 'Heading2'),
|
|
||||||
labelValue('Informe', input.code),
|
|
||||||
labelValue('Acta fuente', text(snapshot.source.actCode ?? snapshot.act.code)),
|
|
||||||
labelValue('Inspección', text(snapshot.source.inspectionCode ?? snapshot.inspection.code)),
|
labelValue('Inspección', text(snapshot.source.inspectionCode ?? snapshot.inspection.code)),
|
||||||
labelValue('Fecha de inspección', isoDate(snapshot.act.occurredAt)),
|
labelValue('Área o Yacimiento', text(input.scopeName ?? input.areaName)),
|
||||||
labelValue('Urgencia', urgency(snapshot.act.urgency)),
|
labelValue('Empresa inspeccionada', text(input.companyName)),
|
||||||
labelValue('Plazo', deadline(snapshot.act)),
|
paragraph('OBJETIVOS', 'Heading1'),
|
||||||
labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')),
|
labelValue('General', 'Documentar los resultados de la inspección consignados en el Acta fuente.'),
|
||||||
paragraph('Resumen ejecutivo', 'Heading1'),
|
labelValue('Particular', 'Analizar los hallazgos y el estado de las instalaciones inspeccionadas para definir las acciones y verificaciones que correspondan.'),
|
||||||
paragraph(executive),
|
paragraph('MARCO LEGAL', 'Heading1'),
|
||||||
paragraph('Descripción / análisis técnico', 'Heading1'),
|
...(legalBasis.length ? legalBasis.map((basis) => paragraph(basis)) : [paragraph('No se consignó normativa específica en los hallazgos del Acta fuente.')]),
|
||||||
paragraph(description),
|
paragraph('DESCRIPCIÓN Y ANÁLISIS TÉCNICO', 'Heading1'),
|
||||||
paragraph('Acta fuente · contenido inmutable', 'Heading1'),
|
paragraph(input.reportDescription?.trim() || `Según el Acta ${text(snapshot.act.code)}, se documentaron ${quantity} durante la inspección. Se detallan las observaciones y evidencias consignadas a continuación.`),
|
||||||
paragraph('El bloque siguiente reproduce información proveniente del Acta sellada. Debe conservarse sin alterar su sentido ni sustituir los Hallazgos originales.'),
|
paragraph('FOTOS Y HALLAZGOS', 'Heading1'),
|
||||||
labelValue('Resumen del Acta', text(snapshot.act.summary)),
|
];
|
||||||
labelValue('Observaciones del Acta', text(snapshot.act.observations)),
|
if (!snapshot.findings.length) entries.push(paragraph('El Acta fuente no registra hallazgos.'));
|
||||||
labelValue('Representante de la empresa', text(snapshot.responsible.fullName)),
|
const shownPhotos = new Set<number>();
|
||||||
labelValue('DNI', text(snapshot.responsible.documentNumber)),
|
for (const finding of snapshot.findings) {
|
||||||
labelValue('Cargo / función', text(snapshot.responsible.position)),
|
entries.push(paragraph(`${text(finding.code)} ${text(finding.title)}`, 'Heading2'));
|
||||||
labelValue('Email', text(snapshot.responsible.email)),
|
entries.push(labelValue('Instalación', text(snapshot.inventories.find((item) => text(item.id) === text(finding.assetId))?.name)));
|
||||||
paragraph('Inventario inspeccionado', 'Heading1'),
|
entries.push(paragraph(text(finding.description)));
|
||||||
inventoryRows.length
|
if (finding.legalBasis) entries.push(labelValue('Normativa', text(finding.legalBasis)));
|
||||||
? table(['Código', 'Nombre', 'Tipo'], inventoryRows)
|
if (finding.severity != null) entries.push(labelValue('Gravedad', `${text(finding.severity)}/10`));
|
||||||
: paragraph('No se registraron elementos de Inventario en el Acta.'),
|
for (let index = 0; index < photos.length; index++) {
|
||||||
paragraph('Hallazgos', 'Heading1'),
|
const photo = photos[index]!;
|
||||||
findingRows.length
|
if (photo.findingId !== text(finding.id) && photo.assetId !== text(finding.assetId)) continue;
|
||||||
? table(['Código', 'Título', 'Descripción', 'Reincidencia', 'Gravedad'], findingRows)
|
shownPhotos.add(index);
|
||||||
: paragraph('El Acta no contiene Hallazgos.'),
|
entries.push(drawing(index + 3, photo.buffer, photo.title || text(finding.title)));
|
||||||
paragraph('Firmas y manifestaciones', 'Heading1'),
|
entries.push(paragraph(`Fotografía vinculada · SHA-256 ${photo.sha256}`));
|
||||||
signatureRows.length
|
}
|
||||||
? table(['Tipo', 'Firmante', 'Estado', 'Manifestación', 'Fecha'], signatureRows)
|
}
|
||||||
: paragraph('No se registraron firmas en la instantánea sellada.'),
|
const otherPhotos = photos.map((photo, index) => ({ photo, index }))
|
||||||
paragraph('Integridad de la fuente', 'Heading1'),
|
.filter(({ photo, index }) => photo.assetId && !shownPhotos.has(index));
|
||||||
labelValue('Hash de la fuente del INF', input.frozenSha256),
|
if (otherPhotos.length) {
|
||||||
labelValue('Hash del Acta sellada', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)),
|
entries.push(paragraph('OTRAS INSTALACIONES INSPECCIONADAS', 'Heading1'));
|
||||||
labelValue('Hash del contenido bloqueado', text(snapshot.sealedAct.lockedSha256)),
|
for (const { photo, index } of otherPhotos) {
|
||||||
paragraph('Este INF permanece editable mientras está en preparación. La edición del informe no modifica el Acta fuente ni los Hallazgos contenidos en ella. La versión oficial será la que se registre posteriormente en GEDO con su identificador IF y PDF oficial.'),
|
entries.push(labelValue('Instalación', text(photo.title)));
|
||||||
].join('');
|
entries.push(drawing(index + 3, photo.buffer, photo.title || 'Fotografía de instalación'));
|
||||||
|
entries.push(paragraph(`Fotografía de inventario · SHA-256 ${photo.sha256}`));
|
||||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>`;
|
}
|
||||||
|
}
|
||||||
|
entries.push(
|
||||||
|
paragraph('CONCLUSIONES', 'Heading1'),
|
||||||
|
paragraph(input.executiveSummary?.trim() || `La inspección registró ${quantity}. Su seguimiento y la respuesta de la empresa se documentan en el Informe.`),
|
||||||
|
paragraph('ACTA FUENTE E INTEGRIDAD', 'Heading1'),
|
||||||
|
labelValue('Acta sellada', text(snapshot.source.actCode ?? snapshot.act.code)),
|
||||||
|
labelValue('SHA-256 del cierre del Acta', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)),
|
||||||
|
labelValue('SHA-256 de la fuente del Informe', input.frozenSha256),
|
||||||
|
);
|
||||||
|
const body = entries.join('');
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildZip(entries: ZipEntry[]): Buffer {
|
function buildZip(entries: ZipEntry[]): Buffer {
|
||||||
@@ -232,10 +256,13 @@ function buildZip(entries: ZipEntry[]): Buffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buffer; sha256: string } {
|
export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buffer; sha256: string } {
|
||||||
|
const logo = readFileSync(resolve(process.cwd(), 'assets/logo-mendoza.png'));
|
||||||
|
const photos = input.photos ?? [];
|
||||||
|
const mediaRelationships = photos.map((photo, index) => `<Relationship Id="rId${index + 3}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/photo-${index + 1}.${photo.buffer.subarray(0,4).toString('hex') === '89504e47' ? 'png' : 'jpg'}"/>`).join('');
|
||||||
const entries: ZipEntry[] = [
|
const entries: ZipEntry[] = [
|
||||||
{
|
{
|
||||||
name: '[Content_Types].xml',
|
name: '[Content_Types].xml',
|
||||||
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>', 'utf8'),
|
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="jpg" ContentType="image/jpeg"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>', 'utf8'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '_rels/.rels',
|
name: '_rels/.rels',
|
||||||
@@ -243,13 +270,15 @@ export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buf
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'word/_rels/document.xml.rels',
|
name: 'word/_rels/document.xml.rels',
|
||||||
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>', 'utf8'),
|
data: Buffer.from(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/logo-mendoza.png"/>${mediaRelationships}</Relationships>`, 'utf8'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'word/styles.xml',
|
name: 'word/styles.xml',
|
||||||
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><w:sz w:val="20"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="34"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="28"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="22"/></w:rPr></w:style></w:styles>', 'utf8'),
|
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial"/><w:sz w:val="20"/></w:rPr><w:pPr><w:spacing w:after="120" w:line="300" w:lineRule="auto"/></w:pPr></w:style><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="34"/></w:rPr><w:pPr><w:spacing w:before="160" w:after="180"/></w:pPr></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="26"/></w:rPr><w:pPr><w:keepNext/><w:spacing w:before="260" w:after="130"/></w:pPr></w:style><w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="22"/></w:rPr><w:pPr><w:keepNext/><w:spacing w:before="180" w:after="100"/></w:pPr></w:style></w:styles>', 'utf8'),
|
||||||
},
|
},
|
||||||
{ name: 'word/document.xml', data: Buffer.from(documentXml(input), 'utf8') },
|
{ name: 'word/document.xml', data: Buffer.from(documentXml(input, logo), 'utf8') },
|
||||||
|
{ name: 'word/media/logo-mendoza.png', data: logo },
|
||||||
|
...photos.map((photo, index) => ({ name: `word/media/photo-${index + 1}.${photo.buffer.subarray(0,4).toString('hex') === '89504e47' ? 'png' : 'jpg'}`, data: photo.buffer })),
|
||||||
{
|
{
|
||||||
name: 'docProps/core.xml',
|
name: 'docProps/core.xml',
|
||||||
data: Buffer.from(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>${xmlEscape(input.title)}</dc:title><dc:creator>DH Inspección</dc:creator><cp:lastModifiedBy>DH Inspección</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">${input.generatedAt.toISOString()}</dcterms:created></cp:coreProperties>`, 'utf8'),
|
data: Buffer.from(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>${xmlEscape(input.title)}</dc:title><dc:creator>DH Inspección</dc:creator><cp:lastModifiedBy>DH Inspección</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">${input.generatedAt.toISOString()}</dcterms:created></cp:coreProperties>`, 'utf8'),
|
||||||
|
|||||||
@@ -5,11 +5,16 @@ import { Injectable, InternalServerErrorException, NotFoundException } from '@ne
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { buildInspectionReportWord } from './inspection-report-word-builder';
|
import { buildInspectionReportWord } from './inspection-report-word-builder';
|
||||||
|
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
||||||
|
|
||||||
const WORD_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
const WORD_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||||
|
|
||||||
interface WordRow {
|
interface WordRow {
|
||||||
id: string;
|
id: string;
|
||||||
|
actId: string;
|
||||||
|
companyName: string | null;
|
||||||
|
areaName: string | null;
|
||||||
|
scopeName: string | null;
|
||||||
code: string;
|
code: string;
|
||||||
title: string;
|
title: string;
|
||||||
executiveSummary: string | null;
|
executiveSummary: string | null;
|
||||||
@@ -30,7 +35,7 @@ interface WordRow {
|
|||||||
export class InspectionReportWordService {
|
export class InspectionReportWordService {
|
||||||
private readonly root: string;
|
private readonly root: string;
|
||||||
|
|
||||||
constructor(private readonly dataSource: DataSource, config: ConfigService) {
|
constructor(private readonly dataSource: DataSource, private readonly actPdf: InspectionActPdfService, config: ConfigService) {
|
||||||
const configured = config.get<string>('INSPECTION_REPORT_WORD_ROOT')
|
const configured = config.get<string>('INSPECTION_REPORT_WORD_ROOT')
|
||||||
?? '/app/storage/asset-media/inspection-reports-word';
|
?? '/app/storage/asset-media/inspection-reports-word';
|
||||||
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path');
|
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path');
|
||||||
@@ -48,6 +53,7 @@ export class InspectionReportWordService {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
const photos = (await this.actPdf.fieldImages(row.actId)).filter((image) => image.findingId || image.assetId);
|
||||||
const built = buildInspectionReportWord({
|
const built = buildInspectionReportWord({
|
||||||
code: row.code,
|
code: row.code,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
@@ -56,6 +62,10 @@ export class InspectionReportWordService {
|
|||||||
frozenSnapshot: row.frozenSnapshot,
|
frozenSnapshot: row.frozenSnapshot,
|
||||||
executiveSummary: row.executiveSummary,
|
executiveSummary: row.executiveSummary,
|
||||||
reportDescription: row.reportDescription,
|
reportDescription: row.reportDescription,
|
||||||
|
companyName: row.companyName,
|
||||||
|
areaName: row.areaName,
|
||||||
|
scopeName: row.scopeName,
|
||||||
|
photos,
|
||||||
});
|
});
|
||||||
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||||
const storedName = `${row.id}-${built.sha256.slice(0, 16)}.docx`;
|
const storedName = `${row.id}-${built.sha256.slice(0, 16)}.docx`;
|
||||||
@@ -114,6 +124,69 @@ export class InspectionReportWordService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The prior editable Word and its revision history stay available unchanged.
|
||||||
|
async consolidatedContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
||||||
|
const [existing] = await this.dataSource.query(`
|
||||||
|
SELECT stored_name AS "storedName",original_name AS "originalName",
|
||||||
|
size_bytes AS "sizeBytes",sha256
|
||||||
|
FROM inspection_report_consolidated_word_revisions
|
||||||
|
WHERE report_id=$1 AND template_version=2
|
||||||
|
`, [reportId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
||||||
|
if (existing) return this.verifiedConsolidated(existing);
|
||||||
|
const row = await this.load(reportId);
|
||||||
|
const photos = (await this.actPdf.fieldImages(row.actId)).filter((image) => image.findingId || image.assetId);
|
||||||
|
const built = buildInspectionReportWord({
|
||||||
|
code: row.code, title: row.title, generatedAt: new Date(row.generatedAt),
|
||||||
|
frozenSha256: row.frozenSha256, frozenSnapshot: row.frozenSnapshot,
|
||||||
|
executiveSummary: row.executiveSummary, reportDescription: row.reportDescription,
|
||||||
|
companyName: row.companyName, areaName: row.areaName, scopeName: row.scopeName, photos,
|
||||||
|
});
|
||||||
|
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||||
|
const storedName = `${row.id}-consolidado-${built.sha256.slice(0, 24)}.docx`;
|
||||||
|
const originalName = `${row.code}-consolidado.docx`;
|
||||||
|
await writeFile(resolve(this.root, storedName), built.buffer, { flag: 'wx', mode: 0o600 }).catch(async (error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code !== 'EEXIST') throw error;
|
||||||
|
const previous = await this.verifiedConsolidated({
|
||||||
|
storedName, originalName, sizeBytes: built.buffer.length, sha256: built.sha256,
|
||||||
|
});
|
||||||
|
if (!(await readFile(previous.filePath)).equals(built.buffer)) throw this.storageError();
|
||||||
|
});
|
||||||
|
await this.dataSource.query(`
|
||||||
|
INSERT INTO inspection_report_consolidated_word_revisions(report_id,template_version,stored_name,original_name,size_bytes,sha256)
|
||||||
|
VALUES($1,2,$2,$3,$4,$5) ON CONFLICT (report_id,template_version) DO NOTHING
|
||||||
|
`, [reportId, storedName, originalName, built.buffer.length, built.sha256]);
|
||||||
|
const [saved] = await this.dataSource.query(`
|
||||||
|
SELECT stored_name AS "storedName",original_name AS "originalName",
|
||||||
|
size_bytes AS "sizeBytes",sha256
|
||||||
|
FROM inspection_report_consolidated_word_revisions WHERE report_id=$1 AND template_version=2
|
||||||
|
`, [reportId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
||||||
|
if (!saved) throw this.storageError();
|
||||||
|
return this.verifiedConsolidated(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async consolidatedRevisionContent(reportId: string, version: number): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
||||||
|
if (![1, 2].includes(version)) throw new NotFoundException('Versión documental inexistente');
|
||||||
|
const [row] = await this.dataSource.query(`
|
||||||
|
SELECT stored_name AS "storedName",original_name AS "originalName",
|
||||||
|
size_bytes AS "sizeBytes",sha256
|
||||||
|
FROM inspection_report_consolidated_word_revisions
|
||||||
|
WHERE report_id=$1 AND template_version=$2
|
||||||
|
`, [reportId, version]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
||||||
|
if (!row) throw new NotFoundException('Versión documental inexistente');
|
||||||
|
return this.verifiedConsolidated(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifiedConsolidated(row: { storedName: string; originalName: string; sizeBytes: number; sha256: string }): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
||||||
|
if (!/^[A-Za-z0-9_.-]+$/.test(row.storedName)) throw this.storageError();
|
||||||
|
const filePath = resolve(this.root, row.storedName);
|
||||||
|
if (!filePath.startsWith(`${this.root}/`)) throw this.storageError();
|
||||||
|
const file = await stat(filePath).catch(() => null);
|
||||||
|
if (!file?.isFile() || file.size !== Number(row.sizeBytes)) throw this.storageError();
|
||||||
|
const buffer = await readFile(filePath);
|
||||||
|
if (createHash('sha256').update(buffer).digest('hex') !== row.sha256) throw this.storageError();
|
||||||
|
return { filePath, originalName: row.originalName, mimeType: WORD_MIME };
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureInitialRevision(row: WordRow): Promise<void> {
|
private async ensureInitialRevision(row: WordRow): Promise<void> {
|
||||||
if (
|
if (
|
||||||
row.wordStatus !== 'READY'
|
row.wordStatus !== 'READY'
|
||||||
@@ -150,21 +223,26 @@ export class InspectionReportWordService {
|
|||||||
|
|
||||||
private async load(reportId: string): Promise<WordRow> {
|
private async load(reportId: string): Promise<WordRow> {
|
||||||
const [row] = await this.dataSource.query(`
|
const [row] = await this.dataSource.query(`
|
||||||
SELECT id,code,title,
|
SELECT report.id,report.act_id AS "actId",report.code,report.title,
|
||||||
executive_summary AS "executiveSummary",
|
company.name AS "companyName",area.name AS "areaName",scope.name AS "scopeName",
|
||||||
report_description AS "reportDescription",
|
report.executive_summary AS "executiveSummary",
|
||||||
generated_at AS "generatedAt",
|
report.report_description AS "reportDescription",
|
||||||
frozen_sha256 AS "frozenSha256",
|
report.generated_at AS "generatedAt",
|
||||||
frozen_snapshot AS "frozenSnapshot",
|
report.frozen_sha256 AS "frozenSha256",
|
||||||
word_status AS "wordStatus",
|
report.frozen_snapshot AS "frozenSnapshot",
|
||||||
word_original_name AS "wordOriginalName",
|
report.word_status AS "wordStatus",
|
||||||
word_stored_name AS "wordStoredName",
|
report.word_original_name AS "wordOriginalName",
|
||||||
word_mime_type AS "wordMimeType",
|
report.word_stored_name AS "wordStoredName",
|
||||||
|
report.word_mime_type AS "wordMimeType",
|
||||||
word_size_bytes::integer AS "wordSizeBytes",
|
word_size_bytes::integer AS "wordSizeBytes",
|
||||||
word_sha256 AS "wordSha256",
|
report.word_sha256 AS "wordSha256",
|
||||||
generated_by AS "generatedBy"
|
report.generated_by AS "generatedBy"
|
||||||
FROM inspection_reports
|
FROM inspection_reports report
|
||||||
WHERE id=$1
|
JOIN inspection_visits visit ON visit.id=report.visit_id
|
||||||
|
LEFT JOIN assets company ON company.id=visit.operator_company_id
|
||||||
|
LEFT JOIN assets area ON area.id=visit.operational_area_id
|
||||||
|
LEFT JOIN assets scope ON scope.id=visit.scope_asset_id
|
||||||
|
WHERE report.id=$1
|
||||||
`, [reportId]) as WordRow[];
|
`, [reportId]) as WordRow[];
|
||||||
if (!row) {
|
if (!row) {
|
||||||
throw new NotFoundException({
|
throw new NotFoundException({
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DataSource, EntityManager } from 'typeorm';
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
import { administrationAuditContext } from '../administration/common/administration-audit';
|
import { administrationAuditContext } from '../administration/common/administration-audit';
|
||||||
|
import { ActAdministrationService, type UploadedActResponseFile } from '../act-administration/act-administration.service';
|
||||||
|
import type { CreateActCompanyResponseDto } from '../act-administration/dto/create-act-company-response.dto';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +22,7 @@ import {
|
|||||||
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
|
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
|
||||||
import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
|
import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
|
||||||
import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||||
|
import type { SetInspectionReportResponseDeadlineDto } from './dto/set-inspection-report-response-deadline.dto';
|
||||||
|
|
||||||
export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024;
|
export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -56,6 +59,7 @@ export class InspectionReportWorkflowService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
|
private readonly administration: ActAdministrationService,
|
||||||
config: ConfigService,
|
config: ConfigService,
|
||||||
) {
|
) {
|
||||||
const configured = config.get<string>('INSPECTION_REPORT_UPLOAD_ROOT')
|
const configured = config.get<string>('INSPECTION_REPORT_UPLOAD_ROOT')
|
||||||
@@ -220,6 +224,42 @@ export class InspectionReportWorkflowService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setResponseDeadline(
|
||||||
|
reportId: string,
|
||||||
|
dto: SetInspectionReportResponseDeadlineDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
const report = await this.requireOfficializedReport(reportId);
|
||||||
|
const reason = dto.reason ?? `Vencimiento general de respuestas definido desde el Informe ${report.code}`;
|
||||||
|
await this.administration.setDeadline(
|
||||||
|
report.actId,
|
||||||
|
{ responseDueOn: dto.responseDueOn, reason },
|
||||||
|
principal,
|
||||||
|
request,
|
||||||
|
report.id,
|
||||||
|
);
|
||||||
|
return this.getWorkflowView(this.dataSource.manager, reportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addCompanyResponse(
|
||||||
|
reportId: string,
|
||||||
|
dto: CreateActCompanyResponseDto,
|
||||||
|
file: UploadedActResponseFile | undefined,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
const report = await this.requireOfficializedReport(reportId);
|
||||||
|
await this.administration.addResponse(report.actId, dto, file, principal, request, report.id);
|
||||||
|
return this.getWorkflowView(this.dataSource.manager, reportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async companyResponseContent(reportId: string, responseId: string) {
|
||||||
|
const report = await this.getReport(this.dataSource.manager, reportId);
|
||||||
|
if (!report) throw reportNotFound();
|
||||||
|
return this.administration.responseContent(responseId, reportId);
|
||||||
|
}
|
||||||
|
|
||||||
async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
||||||
const [row] = await this.dataSource.query(`
|
const [row] = await this.dataSource.query(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -258,6 +298,23 @@ export class InspectionReportWorkflowService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async followUpContent(reportId: string, followUpId: string): Promise<{ filePath: string; originalName: string; mimeType: string; sizeBytes: number }> {
|
||||||
|
const [item] = await this.dataSource.query(`
|
||||||
|
SELECT original_name AS "originalName",stored_name AS "storedName",
|
||||||
|
mime_type AS "mimeType",size_bytes::integer AS "sizeBytes",sha256
|
||||||
|
FROM inspection_report_follow_ups WHERE report_id=$1 AND id=$2 AND stored_name IS NOT NULL
|
||||||
|
`, [reportId, followUpId]) as Array<{ originalName: string; storedName: string; mimeType: string; sizeBytes: number; sha256: string }>;
|
||||||
|
if (!item) throw new NotFoundException({ code: 'REPORT_FOLLOW_UP_FILE_NOT_FOUND', message: 'Adjunto de seguimiento inexistente' });
|
||||||
|
if (!/^followup-[a-f0-9-]+\.[a-z0-9]+$/.test(item.storedName)) throw this.reportStorageError();
|
||||||
|
const filePath = resolve(this.root, item.storedName);
|
||||||
|
if (!filePath.startsWith(`${this.root}/`)) throw this.reportStorageError();
|
||||||
|
const file = await stat(filePath).catch(() => null);
|
||||||
|
if (!file?.isFile() || file.size !== item.sizeBytes) throw this.reportStorageError();
|
||||||
|
const buffer = await readFile(filePath);
|
||||||
|
if (createHash('sha256').update(buffer).digest('hex') !== item.sha256) throw this.reportStorageError();
|
||||||
|
return { filePath, originalName: item.originalName, mimeType: item.mimeType, sizeBytes: item.sizeBytes };
|
||||||
|
}
|
||||||
|
|
||||||
async addFollowUp(
|
async addFollowUp(
|
||||||
reportId: string,
|
reportId: string,
|
||||||
dto: CreateInspectionReportFollowUpDto,
|
dto: CreateInspectionReportFollowUpDto,
|
||||||
@@ -283,6 +340,15 @@ export class InspectionReportWorkflowService {
|
|||||||
try {
|
try {
|
||||||
return await this.dataSource.transaction(async (manager) => {
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
const report = await this.lockReport(manager, reportId);
|
const report = await this.lockReport(manager, reportId);
|
||||||
|
if (
|
||||||
|
(dto.type === 'COMPANY_NOTE' || dto.type === 'COMPANY_DOCUMENT')
|
||||||
|
&& report.status !== InspectionReportStatus.OFFICIALIZED
|
||||||
|
) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INSPECTION_REPORT_GEDO_REQUIRED_FOR_COMPANY_RESPONSE',
|
||||||
|
message: 'Las respuestas de la empresa se registran después de cargar el PDF oficial de GEDO',
|
||||||
|
});
|
||||||
|
}
|
||||||
const occurredAt = new Date(dto.occurredAt);
|
const occurredAt = new Date(dto.occurredAt);
|
||||||
await manager.query(`
|
await manager.query(`
|
||||||
INSERT INTO inspection_report_follow_ups (
|
INSERT INTO inspection_report_follow_ups (
|
||||||
@@ -390,6 +456,18 @@ export class InspectionReportWorkflowService {
|
|||||||
return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : '';
|
return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async requireOfficializedReport(reportId: string): Promise<ReportRow> {
|
||||||
|
const report = await this.getReport(this.dataSource.manager, reportId);
|
||||||
|
if (!report) throw reportNotFound();
|
||||||
|
if (report.status !== InspectionReportStatus.OFFICIALIZED) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INSPECTION_REPORT_GEDO_REQUIRED',
|
||||||
|
message: 'Primero debe cargarse manualmente el PDF oficial de GEDO',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
private async lockReport(manager: EntityManager, id: string): Promise<ReportRow> {
|
private async lockReport(manager: EntityManager, id: string): Promise<ReportRow> {
|
||||||
const [row] = await manager.query(`
|
const [row] = await manager.query(`
|
||||||
SELECT id,act_id AS "actId",visit_id AS "visitId",code,status,
|
SELECT id,act_id AS "actId",visit_id AS "visitId",code,status,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
|
ParseIntPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
@@ -14,6 +15,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||||
|
import { CreateActCompanyResponseDto } from '../act-administration/dto/create-act-company-response.dto';
|
||||||
|
import { MAX_ACT_RESPONSE_BYTES, type UploadedActResponseFile } from '../act-administration/act-administration.service';
|
||||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
@@ -21,6 +24,7 @@ import { CreateInspectionReportFollowUpDto } from './dto/create-inspection-repor
|
|||||||
import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto';
|
import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto';
|
||||||
import { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
|
import { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
|
||||||
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||||
|
import { SetInspectionReportResponseDeadlineDto } from './dto/set-inspection-report-response-deadline.dto';
|
||||||
import {
|
import {
|
||||||
InspectionReportWorkflowService,
|
InspectionReportWorkflowService,
|
||||||
MAX_INSPECTION_REPORT_FILE_BYTES,
|
MAX_INSPECTION_REPORT_FILE_BYTES,
|
||||||
@@ -69,6 +73,34 @@ export class InspectionReportsController {
|
|||||||
return response.sendFile(content.filePath);
|
return response.sendFile(content.filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/consolidated-word')
|
||||||
|
@RequirePermissions('inspection_reports.read')
|
||||||
|
async consolidatedWordContent(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Res() response: Response,
|
||||||
|
) {
|
||||||
|
const content = await this.word.consolidatedContent(id);
|
||||||
|
response.setHeader('Content-Type', content.mimeType);
|
||||||
|
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
|
||||||
|
response.setHeader('Cache-Control', 'private, no-store');
|
||||||
|
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
return response.sendFile(content.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/consolidated-word/revisions/:version')
|
||||||
|
@RequirePermissions('inspection_reports.read')
|
||||||
|
async consolidatedWordRevision(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Param('version', ParseIntPipe) version: number,
|
||||||
|
@Res() response: Response,
|
||||||
|
) {
|
||||||
|
const content = await this.word.consolidatedRevisionContent(id, version);
|
||||||
|
response.setHeader('Content-Type', content.mimeType);
|
||||||
|
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
|
||||||
|
response.setHeader('Cache-Control', 'private, no-store');
|
||||||
|
return response.sendFile(content.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/gedo-pdf')
|
@Get(':id/gedo-pdf')
|
||||||
@RequirePermissions('inspection_reports.read')
|
@RequirePermissions('inspection_reports.read')
|
||||||
async gedoPdfContent(
|
async gedoPdfContent(
|
||||||
@@ -107,12 +139,72 @@ export class InspectionReportsController {
|
|||||||
return this.workflow.officialize(id, dto, file, principal, request);
|
return this.workflow.officialize(id, dto, file, principal, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id/response-deadline')
|
||||||
|
@RequirePermissions('inspection_reports.generate')
|
||||||
|
setResponseDeadline(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: SetInspectionReportResponseDeadlineDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.workflow.setResponseDeadline(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/company-responses')
|
||||||
|
@RequirePermissions('inspection_reports.generate')
|
||||||
|
@UseInterceptors(FileInterceptor('file', {
|
||||||
|
limits: { fileSize: MAX_ACT_RESPONSE_BYTES, files: 1 },
|
||||||
|
}))
|
||||||
|
addCompanyResponse(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: CreateActCompanyResponseDto,
|
||||||
|
@UploadedFile() file: UploadedActResponseFile | undefined,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.workflow.addCompanyResponse(id, dto, file, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/company-responses/:responseId/content')
|
||||||
|
@RequirePermissions('inspection_reports.read')
|
||||||
|
async companyResponseContent(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Param('responseId', new ParseUUIDPipe({ version: '4' })) responseId: string,
|
||||||
|
@Res() response: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const item = await this.workflow.companyResponseContent(id, responseId);
|
||||||
|
const safeName = item.originalName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
||||||
|
response.setHeader('Content-Type', 'application/pdf');
|
||||||
|
response.setHeader('Content-Length', String(item.sizeBytes));
|
||||||
|
response.setHeader('Content-Disposition', `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(item.originalName)}`);
|
||||||
|
response.setHeader('Cache-Control', 'private, no-store');
|
||||||
|
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
await new Promise<void>((resolveSend, rejectSend) => response.sendFile(item.filePath, (error) => error ? rejectSend(error) : resolveSend()));
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/follow-ups')
|
@Get(':id/follow-ups')
|
||||||
@RequirePermissions('inspection_reports.read')
|
@RequirePermissions('inspection_reports.read')
|
||||||
followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||||
return this.workflow.listFollowUps(id);
|
return this.workflow.listFollowUps(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/follow-ups/:followUpId/content')
|
||||||
|
@RequirePermissions('inspection_reports.read')
|
||||||
|
async followUpContent(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Param('followUpId', new ParseUUIDPipe({ version: '4' })) followUpId: string,
|
||||||
|
@Res() response: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const file = await this.workflow.followUpContent(id, followUpId);
|
||||||
|
const safeName = file.originalName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
||||||
|
response.setHeader('Content-Type', file.mimeType);
|
||||||
|
response.setHeader('Content-Length', String(file.sizeBytes));
|
||||||
|
response.setHeader('Content-Disposition', `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(file.originalName)}`);
|
||||||
|
response.setHeader('Cache-Control', 'private, no-store');
|
||||||
|
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
await new Promise<void>((resolveSend, rejectSend) => response.sendFile(file.filePath, (error) => error ? rejectSend(error) : resolveSend()));
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/follow-ups')
|
@Post(':id/follow-ups')
|
||||||
@RequirePermissions('inspection_reports.generate')
|
@RequirePermissions('inspection_reports.generate')
|
||||||
@UseInterceptors(FileInterceptor('file', {
|
@UseInterceptors(FileInterceptor('file', {
|
||||||
@@ -170,3 +262,39 @@ export class InspectionActPdfController {
|
|||||||
return response.send(content.buffer);
|
return response.send(content.buffer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Controller('inspection-acts/:actId/consolidated-pdf')
|
||||||
|
export class InspectionActConsolidatedPdfController {
|
||||||
|
constructor(private readonly pdf: InspectionActPdfService) {}
|
||||||
|
|
||||||
|
@Get('revisions/:version')
|
||||||
|
@RequirePermissions('inspection_acts.read')
|
||||||
|
async revision(
|
||||||
|
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||||
|
@Param('version', ParseIntPipe) version: number,
|
||||||
|
@Res() response: Response,
|
||||||
|
) {
|
||||||
|
const content = await this.pdf.consolidatedRevisionContent(actId, version);
|
||||||
|
response.setHeader('Content-Type', content.mimeType);
|
||||||
|
response.setHeader('Content-Length', String(content.buffer.length));
|
||||||
|
response.setHeader('Content-Disposition', `inline; filename="${content.originalName.replaceAll('"', '')}"`);
|
||||||
|
response.setHeader('Cache-Control', 'private, no-store');
|
||||||
|
return response.send(content.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions('inspection_acts.read')
|
||||||
|
async content(
|
||||||
|
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||||
|
@Res() response: Response,
|
||||||
|
) {
|
||||||
|
const content = await this.pdf.consolidatedContent(actId);
|
||||||
|
response.setHeader('Content-Type', content.mimeType);
|
||||||
|
response.setHeader('Content-Length', String(content.buffer.length));
|
||||||
|
response.setHeader('Content-Disposition', `inline; filename="${content.originalName.replaceAll('"', '')}"`);
|
||||||
|
response.setHeader('Cache-Control', 'private, no-store');
|
||||||
|
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
return response.send(content.buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { ActAdministrationModule } from '../act-administration/act-administration.module';
|
||||||
import { DocumentDeliveryController } from './document-delivery.controller';
|
import { DocumentDeliveryController } from './document-delivery.controller';
|
||||||
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
||||||
import { InspectionDeadlineAdminController } from './inspection-deadline-admin.controller';
|
import { InspectionDeadlineAdminController } from './inspection-deadline-admin.controller';
|
||||||
@@ -7,16 +8,17 @@ import { InspectionDeadlineAdminService } from './inspection-deadline-admin.serv
|
|||||||
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
||||||
import { InspectionReportWorkflowService } from './inspection-report-workflow.service';
|
import { InspectionReportWorkflowService } from './inspection-report-workflow.service';
|
||||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
import { InspectionReportWordService } from './inspection-report-word.service';
|
||||||
import { InspectionActPdfController, InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
|
import { InspectionActConsolidatedPdfController, InspectionActPdfController, InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
|
||||||
import { InspectionReportsService } from './inspection-reports.service';
|
import { InspectionReportsService } from './inspection-reports.service';
|
||||||
import { SmtpDeliveryService } from './smtp-delivery.service';
|
import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuditModule],
|
imports: [AuditModule, ActAdministrationModule],
|
||||||
controllers: [
|
controllers: [
|
||||||
InspectionReportsController,
|
InspectionReportsController,
|
||||||
InspectionActReportController,
|
InspectionActReportController,
|
||||||
InspectionActPdfController,
|
InspectionActPdfController,
|
||||||
|
InspectionActConsolidatedPdfController,
|
||||||
InspectionDeadlineAdminController,
|
InspectionDeadlineAdminController,
|
||||||
DocumentDeliveryController,
|
DocumentDeliveryController,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ export interface InspectionReportListItem {
|
|||||||
|
|
||||||
export interface InspectionReportView extends InspectionReportListItem {
|
export interface InspectionReportView extends InspectionReportListItem {
|
||||||
frozenSnapshot: Record<string, unknown>;
|
frozenSnapshot: Record<string, unknown>;
|
||||||
|
responseDueOn: string | null;
|
||||||
|
deadlineReason: string | null;
|
||||||
|
deadlines: Array<{ id: string; reportId: string | null; responseDueOn: string; reason: string; createdAt: Date }>;
|
||||||
|
companyResponses: Array<{
|
||||||
|
id: string; reportId: string | null; receivedOn: string; details: string | null; committedCorrectionOn: string | null;
|
||||||
|
contactName: string | null; contactEmail: string | null; originalName: string | null; sizeBytes: number | null; sha256: string | null; createdAt: Date;
|
||||||
|
}>;
|
||||||
|
findings: Array<{
|
||||||
|
id: string; code: string; title: string; status: string; assetCode: string; assetName: string; responseDueOn: string | null;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingInspectionReportItem {
|
export interface PendingInspectionReportItem {
|
||||||
@@ -491,7 +501,38 @@ export class InspectionReportsService {
|
|||||||
'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1',
|
'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1',
|
||||||
[id],
|
[id],
|
||||||
)) as Array<{ frozenSnapshot: Record<string, unknown> }>;
|
)) as Array<{ frozenSnapshot: Record<string, unknown> }>;
|
||||||
return { ...report, frozenSnapshot: snapshot.frozenSnapshot };
|
const deadlines = await manager.query(`
|
||||||
|
SELECT id,report_id AS "reportId",response_due_on AS "responseDueOn",reason,created_at AS "createdAt"
|
||||||
|
FROM inspection_act_deadline_events
|
||||||
|
WHERE act_id=$1 AND (report_id IS NULL OR report_id=$2)
|
||||||
|
ORDER BY created_at DESC,id DESC
|
||||||
|
`, [report.actId, report.id]) as InspectionReportView['deadlines'];
|
||||||
|
const currentDeadline = deadlines[0] ?? null;
|
||||||
|
const companyResponses = await manager.query(`
|
||||||
|
SELECT id,report_id AS "reportId",received_on AS "receivedOn",details,
|
||||||
|
committed_correction_on AS "committedCorrectionOn",contact_name AS "contactName",contact_email AS "contactEmail",
|
||||||
|
original_name AS "originalName",size_bytes::integer AS "sizeBytes",sha256,created_at AS "createdAt"
|
||||||
|
FROM inspection_act_company_responses
|
||||||
|
WHERE act_id=$1 AND (report_id IS NULL OR report_id=$2)
|
||||||
|
ORDER BY received_on DESC,created_at DESC,id DESC
|
||||||
|
`, [report.actId, report.id]) as InspectionReportView['companyResponses'];
|
||||||
|
const findings = await manager.query(`
|
||||||
|
SELECT finding.id,finding.code,finding.title,finding.status,asset.code AS "assetCode",asset.name AS "assetName",
|
||||||
|
$2::date AS "responseDueOn"
|
||||||
|
FROM inspection_findings finding
|
||||||
|
JOIN assets asset ON asset.id=finding.asset_id
|
||||||
|
WHERE finding.act_id=$1 AND finding.status<>'VOIDED'
|
||||||
|
ORDER BY finding.finding_number,finding.id
|
||||||
|
`, [report.actId, currentDeadline?.responseDueOn ?? null]) as InspectionReportView['findings'];
|
||||||
|
return {
|
||||||
|
...report,
|
||||||
|
frozenSnapshot: snapshot.frozenSnapshot,
|
||||||
|
responseDueOn: currentDeadline?.responseDueOn ?? null,
|
||||||
|
deadlineReason: currentDeadline?.reason ?? null,
|
||||||
|
deadlines,
|
||||||
|
companyResponses,
|
||||||
|
findings,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> {
|
private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
||||||
import { connect as connectNet, Socket } from 'node:net';
|
import { connect as connectNet, Socket } from 'node:net';
|
||||||
import { connect as connectTls, TLSSocket } from 'node:tls';
|
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 { ConfigService } from '@nestjs/config';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { SmtpSecurityMode } from '../database/entities';
|
import { SmtpSecurityMode } from '../database/entities';
|
||||||
@@ -250,9 +250,9 @@ export class SmtpDeliveryService {
|
|||||||
|
|
||||||
private encryptionKey():Buffer{
|
private encryptionKey():Buffer{
|
||||||
const raw=this.config.get<string>('SMTP_SETTINGS_MASTER_KEY');
|
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');
|
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;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { IsUUID } from 'class-validator';
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
export class OpenMobileInspectionDto {
|
export class OpenMobileInspectionDto {
|
||||||
|
@IsUUID('4')
|
||||||
|
departmentId!: string;
|
||||||
|
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
operationalAreaId!: string;
|
operationalAreaId!: string;
|
||||||
|
|
||||||
|
@IsUUID('4')
|
||||||
|
scopeAssetId!: string;
|
||||||
|
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
operatorCompanyId!: string;
|
operatorCompanyId!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,67 @@ export class InspectionPlanningHierarchyService {
|
|||||||
return { data };
|
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(
|
async operatorsForArea(
|
||||||
areaId: string,
|
areaId: string,
|
||||||
at?: string,
|
at?: string,
|
||||||
|
|||||||
@@ -87,21 +87,41 @@ export class InspectionVisitsController {
|
|||||||
return this.planningHierarchy.operatorsForArea(areaId, at);
|
return this.planningHierarchy.operatorsForArea(areaId, at);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('mobile/planning-context/areas')
|
@Get('mobile/planning-context/departments')
|
||||||
@RequirePermissions('inspections.execute')
|
@RequirePermissions('inspections.execute')
|
||||||
mobilePlanningAreas(@CurrentAuth() principal: AuthPrincipal) {
|
mobilePlanningDepartments(@CurrentAuth() principal: AuthPrincipal) {
|
||||||
assertMobileInspector(principal);
|
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')
|
@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,
|
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
|
||||||
@CurrentAuth() principal: AuthPrincipal,
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
) {
|
) {
|
||||||
assertMobileInspector(principal);
|
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')
|
@Post('mobile/open')
|
||||||
@@ -112,10 +132,15 @@ export class InspectionVisitsController {
|
|||||||
@Req() request: RequestWithContext,
|
@Req() request: RequestWithContext,
|
||||||
) {
|
) {
|
||||||
assertMobileInspector(principal);
|
assertMobileInspector(principal);
|
||||||
// Android conserva por compatibilidad el alcance a nivel Área hasta que su
|
await this.planningHierarchy.validateMobileSelection(
|
||||||
// flujo también solicite Yacimiento. No se mezcla con la creación WEB F6.1.
|
dto.departmentId,
|
||||||
const created = await this.visits.create({
|
dto.operationalAreaId,
|
||||||
|
dto.scopeAssetId,
|
||||||
|
dto.operatorCompanyId,
|
||||||
|
);
|
||||||
|
const created = await this.planningCreate.create({
|
||||||
operationalAreaId: dto.operationalAreaId,
|
operationalAreaId: dto.operationalAreaId,
|
||||||
|
scopeAssetId: dto.scopeAssetId,
|
||||||
operatorCompanyId: dto.operatorCompanyId,
|
operatorCompanyId: dto.operatorCompanyId,
|
||||||
plannedStartAt: new Date().toISOString(),
|
plannedStartAt: new Date().toISOString(),
|
||||||
leadInspectorUserId: principal.userId,
|
leadInspectorUserId: principal.userId,
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export const API_VERSION = '0.29.0-8';
|
export const API_VERSION = '0.29.0-17';
|
||||||
export const API_PHASE = 'F6.8';
|
export const API_PHASE = 'F6.17';
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
const api = (path: string) => readFileSync(resolve(process.cwd(), 'src', path), 'utf8');
|
||||||
|
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', 'src', path), 'utf8');
|
||||||
|
|
||||||
|
test('Acta PDF renders Hallazgos and finding evidence, never standalone Inventory or field photos', () => {
|
||||||
|
const source = api('inspection-reports/inspection-act-pdf-builder.ts');
|
||||||
|
assert.match(source, /section\('5\. Hallazgos'\)/);
|
||||||
|
assert.match(source, /item\.findingId === text\(finding\.id\)/);
|
||||||
|
assert.doesNotMatch(source, /Instalaciones inspeccionadas/);
|
||||||
|
assert.doesNotMatch(source, /Otras instalaciones inspeccionadas/);
|
||||||
|
assert.doesNotMatch(source, /item\.assetId === text\(finding\.assetId\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Dashboard Acta shows only Hallazgos and evidence directly attached to them', () => {
|
||||||
|
const media = web('features/inspections/InspectionActMediaPanel.tsx');
|
||||||
|
const editor = web('pages/InspectionActEditorPage.tsx');
|
||||||
|
assert.match(media, /listInspectionFindingEvidence/);
|
||||||
|
assert.doesNotMatch(media, /listInspectionActFieldMedia/);
|
||||||
|
assert.doesNotMatch(media, /getAssetMediaBlob/);
|
||||||
|
assert.doesNotMatch(media, /Fotos de otras instalaciones/);
|
||||||
|
assert.doesNotMatch(editor, /Instalaciones inspeccionadas/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Company signing view exposes Hallazgos as Acta content without a standalone Inventory list', () => {
|
||||||
|
const service = api('inspection-closing/company-signature-invite.service.ts');
|
||||||
|
const viewBody = service.slice(service.indexOf(' async view(token: string)'), service.indexOf(' async sign('));
|
||||||
|
const page = web('pages/CompanySignaturePage.tsx');
|
||||||
|
assert.match(viewBody, /findings/);
|
||||||
|
assert.doesNotMatch(viewBody, /inventories/);
|
||||||
|
assert.match(page, />Hallazgos</);
|
||||||
|
assert.doesNotMatch(page, /Inventario inspeccionado/);
|
||||||
|
assert.doesNotMatch(page, /view\.inventories/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Acta sealed snapshot excludes Inventory that has no Hallazgo and PDF skips standalone asset media', () => {
|
||||||
|
const closing = api('inspection-closing/inspection-closing.service.ts');
|
||||||
|
const pdfService = api('inspection-reports/inspection-act-pdf.service.ts');
|
||||||
|
const snapshotBody = closing.slice(closing.indexOf(' private async buildLockedSnapshot('), closing.indexOf(' private async deadlinePolicy('));
|
||||||
|
assert.match(snapshotBody, /EXISTS \(\s*SELECT 1 FROM inspection_findings finding/);
|
||||||
|
assert.match(snapshotBody, /finding\.asset_id=asset\.id/);
|
||||||
|
assert.match(snapshotBody, /finding\.status<>'VOIDED'/);
|
||||||
|
assert.match(pdfService, /fieldImages\(actId, false\)/);
|
||||||
|
assert.match(pdfService, /includeAssetPhotos \? await this\.dataSource\.query/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Acta PDF explains each Hallazgo with frozen territorial, technical and recurrence context', () => {
|
||||||
|
const pdf = api('inspection-reports/inspection-act-pdf-builder.ts');
|
||||||
|
const closing = api('inspection-closing/inspection-closing.service.ts');
|
||||||
|
assert.match(pdf, /Contexto territorial y operativo/);
|
||||||
|
assert.match(pdf, /Inspector\/a responsable/);
|
||||||
|
assert.match(pdf, /Tipo de instalación/);
|
||||||
|
assert.match(pdf, /Qué se constató/);
|
||||||
|
assert.match(pdf, /Referencia de catálogo/);
|
||||||
|
assert.match(pdf, /Normativa \/ Base legal/);
|
||||||
|
assert.match(pdf, /Reincidencia/);
|
||||||
|
assert.match(pdf, /Alcance de la inspección/);
|
||||||
|
assert.match(pdf, /scopeTypeCode/);
|
||||||
|
assert.match(pdf, /Antecedente relacionado/);
|
||||||
|
assert.match(closing, /DH-ACT-LIFECYCLE-V5/);
|
||||||
|
assert.match(closing, /AS department/);
|
||||||
|
assert.match(closing, /AS "leadInspector"/);
|
||||||
|
assert.match(closing, /AS "installationTypeName"/);
|
||||||
|
assert.match(closing, /AS recurrence/);
|
||||||
|
const service = api('inspection-reports/inspection-act-pdf.service.ts');
|
||||||
|
assert.match(service, /yacimiento_from_findings/);
|
||||||
|
});
|
||||||
@@ -36,15 +36,14 @@ test('F4 document center and inspection detail consume the F4 act client', () =>
|
|||||||
test('F4 closure UI treats sealing as the definitive act state', () => {
|
test('F4 closure UI treats sealing as the definitive act state', () => {
|
||||||
assert.match(closurePanel, /getInspectionClosureF4/);
|
assert.match(closurePanel, /getInspectionClosureF4/);
|
||||||
assert.match(closurePanel, /act\.status === 'SEALED'/);
|
assert.match(closurePanel, /act\.status === 'SEALED'/);
|
||||||
assert.match(closurePanel, /ACTA SELLADA/);
|
assert.match(closurePanel, /Participantes y firmas/);
|
||||||
assert.match(closurePanel, /manifestación de la empresa puede completarse/);
|
assert.doesNotMatch(closurePanel, /Pendiente de evento válido/);
|
||||||
assert.match(closurePanel, /Pendiente de evento válido/);
|
|
||||||
assert.doesNotMatch(closurePanel, /reabrir ni cerrar el acta/);
|
assert.doesNotMatch(closurePanel, /reabrir ni cerrar el acta/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('F4 presentation keeps new lifecycle labels while retaining explicit legacy readability', () => {
|
test('F4 presentation keeps new lifecycle labels while retaining explicit legacy readability', () => {
|
||||||
assert.match(presentation, /LOCKED: 'Bloqueada/);
|
assert.match(presentation, /LOCKED: 'Para firmar/);
|
||||||
assert.match(presentation, /SEALED: 'Sellada'/);
|
assert.match(presentation, /SEALED: 'Firmada y cerrada'/);
|
||||||
assert.match(presentation, /READY: 'Lista para cerrar · legado'/);
|
assert.match(presentation, /READY: 'Lista para cerrar · legado'/);
|
||||||
assert.match(presentation, /CLOSED: 'Cerrada · legado'/);
|
assert.match(presentation, /CLOSED: 'Firmada y cerrada'/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { API_PHASE, API_VERSION } from '../../src/version';
|
import { API_PHASE, API_VERSION } from '../../src/version';
|
||||||
|
|
||||||
test('health metadata reports the current F6.8 release', () => {
|
test('health metadata reports the current F6.17 release', () => {
|
||||||
assert.equal(API_PHASE, 'F6.8');
|
assert.equal(API_PHASE, 'F6.17');
|
||||||
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
|
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
|
||||||
assert.equal(API_VERSION, pkg.version);
|
assert.equal(API_VERSION, pkg.version);
|
||||||
assert.equal(API_VERSION, '0.29.0-8');
|
assert.equal(API_VERSION, '0.29.0-17');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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', () => {
|
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');
|
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
|
||||||
|
|
||||||
assert.match(gradle, /versionCode = 38/);
|
assert.match(gradle, /versionCode = 41/);
|
||||||
assert.match(gradle, /versionName = "0\.19\.10"/);
|
assert.match(gradle, /versionName = "0\.19\.13"/);
|
||||||
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
||||||
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
|
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/);
|
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 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');
|
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\/departments'\)/);
|
||||||
assert.match(controller, /@Get\('mobile\/planning-context\/areas\/:areaId\/operators'\)/);
|
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, /@Post\('mobile\/open'\)/);
|
||||||
assert.match(controller, /@RequirePermissions\('inspections\.execute'\)/);
|
assert.match(controller, /validateMobileSelection/);
|
||||||
assert.match(controller, /assertMobileInspector\(principal\)/);
|
assert.match(controller, /scopeAssetId: dto\.scopeAssetId/);
|
||||||
assert.match(controller, /leadInspectorUserId: principal\.userId/);
|
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\.plan\(created\.id/);
|
||||||
assert.match(controller, /this\.lifecycle\.start\(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, /operationalAreaId!: string/);
|
||||||
|
assert.match(dto, /scopeAssetId!: string/);
|
||||||
assert.match(dto, /operatorCompanyId!: 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 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 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');
|
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, /planning-context\/departments/);
|
||||||
assert.match(client, /inspection-visits\/mobile\/open/);
|
assert.match(client, /departments\/\{departmentId\}\/areas/);
|
||||||
assert.match(home, /Abrir inspección/);
|
assert.match(client, /areas\/\{areaId\}\/yacimientos/);
|
||||||
assert.match(home, /Abrir inspección ahora/);
|
assert.match(client, /yacimientos\/\{yacimientoId\}\/operators/);
|
||||||
assert.match(home, /Operadora vigente/);
|
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(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\)/);
|
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];
|
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
|
||||||
|
|
||||||
assert.equal(visibleVersion, pkg.version);
|
assert.equal(visibleVersion, pkg.version);
|
||||||
assert.match(version, /APP_PHASE\s*=\s*'F6\.8/);
|
assert.match(version, /APP_PHASE\s*=\s*'F6\.17/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
|
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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(), 'src', path), 'utf8');
|
||||||
|
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', 'src', path), 'utf8');
|
||||||
|
|
||||||
|
test('F6.11 keeps GEDO officialization manual and does not activate response deadlines automatically', () => {
|
||||||
|
const workflow = api('inspection-reports/inspection-report-workflow.service.ts');
|
||||||
|
const page = web('pages/ReportDetailPage.tsx');
|
||||||
|
assert.match(workflow, /GEDO oficializa el INF, pero no equivale por sí solo a la notificación/);
|
||||||
|
assert.doesNotMatch(workflow.slice(workflow.indexOf(' async officialize('), workflow.indexOf(' async setResponseDeadline(')), /setDeadline\(/);
|
||||||
|
assert.match(page, /GEDO no se consulta automáticamente/);
|
||||||
|
assert.match(page, /no crea respuestas ni vencimientos automáticamente/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.11 stores new deadlines and company responses with both Report and Act relation', () => {
|
||||||
|
const migration = api('database/migrations/1790139000000-f6-11-report-response-workflow.ts');
|
||||||
|
const administration = api('act-administration/act-administration.service.ts');
|
||||||
|
assert.match(migration, /inspection_act_deadline_events ADD COLUMN IF NOT EXISTS report_id uuid/);
|
||||||
|
assert.match(migration, /inspection_act_company_responses ADD COLUMN IF NOT EXISTS report_id uuid/);
|
||||||
|
assert.match(migration, /report\.id=NEW\.report_id AND report\.act_id=NEW\.act_id/);
|
||||||
|
assert.doesNotMatch(migration, /UPDATE inspection_act_deadline_events|UPDATE inspection_act_company_responses/);
|
||||||
|
assert.match(administration, /INSERT INTO inspection_act_deadline_events \(id, act_id, report_id/);
|
||||||
|
assert.match(administration, /INSERT INTO inspection_act_company_responses \(id, act_id, report_id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.11 projects one Act response deadline to every non-voided finding', () => {
|
||||||
|
const administration = api('act-administration/act-administration.service.ts');
|
||||||
|
const reports = api('inspection-reports/inspection-reports.service.ts');
|
||||||
|
assert.match(administration, /UPDATE inspection_findings[\s\S]*SET correction_due_on=\$2[\s\S]*WHERE act_id=\$1 AND status<>'VOIDED'/);
|
||||||
|
assert.match(administration, /sharedDeadline: true/);
|
||||||
|
assert.match(reports, /\$2::date AS "responseDueOn"/);
|
||||||
|
assert.match(reports, /currentDeadline\?\.responseDueOn/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.11 enables formal responses only after the official GEDO PDF exists', () => {
|
||||||
|
const workflow = api('inspection-reports/inspection-report-workflow.service.ts');
|
||||||
|
const controller = api('inspection-reports/inspection-reports.controller.ts');
|
||||||
|
const page = web('pages/ReportDetailPage.tsx');
|
||||||
|
assert.match(workflow, /requireOfficializedReport\(reportId\)/);
|
||||||
|
assert.match(workflow, /Primero debe cargarse manualmente el PDF oficial de GEDO/);
|
||||||
|
assert.match(controller, /@Post\(':id\/company-responses'\)/);
|
||||||
|
assert.match(controller, /@Patch\(':id\/response-deadline'\)/);
|
||||||
|
assert.match(page, /Las respuestas se habilitan después de cargar el PDF oficial de GEDO/);
|
||||||
|
assert.match(page, /Vencimiento común del Acta/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.11 separates formal company responses from internal report follow-up notes', () => {
|
||||||
|
const page = web('pages/ReportDetailPage.tsx');
|
||||||
|
assert.match(page, /RESPUESTAS DE EMPRESA/);
|
||||||
|
assert.match(page, /No usar este bloque para respuestas formales de empresa/);
|
||||||
|
assert.match(page, /<option value="INTERNAL_NOTE">Nota interna<\/option>/);
|
||||||
|
assert.doesNotMatch(page.slice(page.indexOf('Agregar otro antecedente')), /option value="COMPANY_NOTE"/);
|
||||||
|
});
|
||||||
@@ -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, /geometry\.type\.toLowerCase\(\) !== '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,66 @@
|
|||||||
|
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.16 map renders operational locations as GPS point markers without filters', () => {
|
||||||
|
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, /punto\{data\.meta\.count === 1 \? '' : 's'\} GPS/);
|
||||||
|
assert.match(page, /feature\.geometry\.type\.toLowerCase\(\) === 'point'/);
|
||||||
|
assert.match(page, /map-legend/);
|
||||||
|
assert.doesNotMatch(page, /SearchableSelect/);
|
||||||
|
assert.doesNotMatch(page, /mapSearch/);
|
||||||
|
assert.doesNotMatch(page, /toggleLayer/);
|
||||||
|
assert.match(map, /new Marker/);
|
||||||
|
assert.match(map, /pointCoordinates/);
|
||||||
|
assert.match(map, /visualOffsets/);
|
||||||
|
assert.match(map, /fitToPoints/);
|
||||||
|
assert.doesNotMatch(map, /addSource\('assets'/);
|
||||||
|
assert.doesNotMatch(map, /addLayer\(/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
function collectTsx(directory: string): string[] {
|
||||||
|
return readdirSync(directory).flatMap((name) => {
|
||||||
|
const path = resolve(directory, name);
|
||||||
|
return statSync(path).isDirectory() ? collectTsx(path) : name.endsWith('.tsx') ? [path] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('F6.17 uses one sortable table component for every active WEB table', () => {
|
||||||
|
const webRoot = resolve(process.cwd(), '..', 'web-v2', 'src');
|
||||||
|
const files = collectTsx(webRoot);
|
||||||
|
const rawTables = files.filter((path) => !path.endsWith('components/SortableTable.tsx') && readFileSync(path, 'utf8').includes('<table'));
|
||||||
|
assert.deepEqual(rawTables, []);
|
||||||
|
|
||||||
|
const sortableFiles = files.filter((path) => readFileSync(path, 'utf8').includes('<SortableTable'));
|
||||||
|
assert.equal(sortableFiles.length, 24);
|
||||||
|
const tableCount = sortableFiles.reduce((count, path) => count + (readFileSync(path, 'utf8').match(/<SortableTable/g)?.length ?? 0), 0);
|
||||||
|
assert.equal(tableCount, 28);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.17 sortable headers toggle direction and compare text, dates and numbers', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), '..', 'web-v2', 'src', 'components', 'SortableTable.tsx'), 'utf8');
|
||||||
|
assert.match(source, /aria-sort/);
|
||||||
|
assert.match(source, /ascending/);
|
||||||
|
assert.match(source, /descending/);
|
||||||
|
assert.match(source, /dateValue/);
|
||||||
|
assert.match(source, /numericPattern/);
|
||||||
|
assert.match(source, /localeCompare\(right, 'es-AR'/);
|
||||||
|
assert.match(source, /data-sortable/);
|
||||||
|
});
|
||||||
@@ -40,6 +40,7 @@ test('F6.2 exposes provisional PDF data contracts for Actas and Informes', () =>
|
|||||||
assert.match(projection, /Firmas y manifestaciones/);
|
assert.match(projection, /Firmas y manifestaciones/);
|
||||||
assert.match(projection, /GEDO e integridad/);
|
assert.match(projection, /GEDO e integridad/);
|
||||||
assert.match(projection, /Anexo final/);
|
assert.match(projection, /Anexo final/);
|
||||||
assert.match(actPage, /<DocumentPdfProjection kind="act" \/>/);
|
assert.match(actPage, /getInspectionActConsolidatedPdfBlob/);
|
||||||
assert.match(reportPage, /<DocumentPdfProjection kind="report" \/>/);
|
assert.doesNotMatch(actPage, /DocumentPdfProjection/);
|
||||||
|
assert.doesNotMatch(reportPage, /DocumentPdfProjection/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ test('F6.3 Android follows Inspección → Acta → Hallazgo → Inventario', ()
|
|||||||
assert.doesNotMatch(root, /Text\("Inventario de campo"/);
|
assert.doesNotMatch(root, /Text\("Inventario de campo"/);
|
||||||
assert.match(acts, /Text\("Agregar Hallazgo"\)/);
|
assert.match(acts, /Text\("Agregar Hallazgo"\)/);
|
||||||
assert.match(acts, /model\.createAct\(\)/);
|
assert.match(acts, /model\.createAct\(\)/);
|
||||||
assert.match(acts, /model\.prepareSelectedAct\(closingUrgency\)/);
|
assert.match(acts, /model\.prepareSelectedAct\(closingUrgency, actNarrative\)/);
|
||||||
assert.match(vm, /fun createAct\(\)/);
|
assert.match(vm, /fun createAct\(\)/);
|
||||||
assert.match(vm, /fun prepareSelectedAct\(urgency: String\)/);
|
assert.match(vm, /fun prepareSelectedAct\(urgency: String, narrative: String\)/);
|
||||||
assert.match(vm, /repository\.fieldInventory\(currentVisit\.id, search, parentId\)/);
|
assert.match(vm, /repository\.fieldInventory\(currentVisit\.id, search, parentId\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ test('F6.8 makes the real Acta PDF and photographic record first-class in Dashbo
|
|||||||
assert.match(api('src/inspection-acts/inspection-acts.service.ts'), /capture\.visit_id=act\.visit_id/);
|
assert.match(api('src/inspection-acts/inspection-acts.service.ts'), /capture\.visit_id=act\.visit_id/);
|
||||||
assert.match(page, /Abrir PDF del Acta/);
|
assert.match(page, /Abrir PDF del Acta/);
|
||||||
assert.match(page, /Descargar PDF/);
|
assert.match(page, /Descargar PDF/);
|
||||||
assert.match(media, /Fotos y Hallazgos/);
|
assert.match(media, /Hallazgos del Acta/);
|
||||||
assert.match(media, /Evidencia fotográfica/);
|
assert.match(media, /act-finding-photos/);
|
||||||
assert.match(media, /Fotos tomadas durante esta inspección/);
|
assert.match(media, /listInspectionFindingEvidence/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('F6.8 presents technical families to users as installation and subinstallation types', () => {
|
test('F6.8 presents technical families to users as installation and subinstallation types', () => {
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildInspectionActPdf } from '../../src/inspection-reports/inspection-act-pdf-builder';
|
||||||
|
import { buildInspectionReportWord } from '../../src/inspection-reports/inspection-report-word-builder';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { renderableInspectionImage } from '../../src/inspection-reports/inspection-document-images';
|
||||||
|
|
||||||
|
const digest = (data: Buffer) => createHash('sha256').update(data).digest('hex');
|
||||||
|
const photo = readFileSync(resolve(process.cwd(), 'assets/logo-mendoza.png'));
|
||||||
|
const locked = {
|
||||||
|
act: { code: 'ACT-TEST-01', occurredAt: '2026-09-14T18:30:00Z',
|
||||||
|
summary: 'Se inspeccionó un tanque.', inspection: { code: 'INSP-TEST-01' } },
|
||||||
|
responsible: { fullName: 'Responsable de prueba', documentNumber: '12345678' },
|
||||||
|
inventories: [{ id: 'asset-1', code: 'CAM-TEST-01', name: 'Tanque de prueba' }],
|
||||||
|
findings: [{ id: 'finding-1', assetId: 'asset-1', code: 'ACT-TEST-H01',
|
||||||
|
title: 'Pérdida', description: 'Se constató una pérdida visible.', severity: 3 }],
|
||||||
|
};
|
||||||
|
const sealed = { lockedSnapshot: locked, finalSha256: 'a'.repeat(64),
|
||||||
|
lockedSha256: 'b'.repeat(64), signatures: [], seal: { serverSealedAt: '2026-09-15T18:30:00Z' } };
|
||||||
|
const evidence = { id: 'photo-1', findingId: 'finding-1', sha256: digest(photo), buffer: photo };
|
||||||
|
|
||||||
|
test('F6.9 consolidated Acta is a hashed PDF 1.4 with actual evidence embedded', async () => {
|
||||||
|
const without = await buildInspectionActPdf(sealed);
|
||||||
|
const withEvidence = await buildInspectionActPdf(sealed, [evidence]);
|
||||||
|
assert.ok(withEvidence.buffer.subarray(0, 8).equals(Buffer.from('%PDF-1.4')));
|
||||||
|
assert.equal(withEvidence.sha256, digest(withEvidence.buffer));
|
||||||
|
assert.ok(withEvidence.buffer.length > without.buffer.length + 3_000);
|
||||||
|
assert.notEqual(withEvidence.sha256, without.sha256);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.9 technical Informe embeds evidence and its institutional source hash', () => {
|
||||||
|
const input = { code: 'INF-TEST-01', title: 'Informe de prueba', generatedAt: new Date('2026-09-15'),
|
||||||
|
frozenSha256: 'c'.repeat(64), frozenSnapshot: { sealedAct: sealed, source: { actCode: locked.act.code } } };
|
||||||
|
const plain = buildInspectionReportWord(input);
|
||||||
|
const withEvidence = buildInspectionReportWord({ ...input, photos: [evidence] });
|
||||||
|
assert.equal(withEvidence.sha256, digest(withEvidence.buffer));
|
||||||
|
assert.ok(withEvidence.buffer.subarray(0, 4).equals(Buffer.from('PK\x03\x04')));
|
||||||
|
assert.ok(withEvidence.buffer.includes(Buffer.from('word/media/photo-1.png')));
|
||||||
|
assert.ok(withEvidence.buffer.includes(photo));
|
||||||
|
assert.ok(withEvidence.buffer.includes(Buffer.from('ACT-TEST-H01')));
|
||||||
|
assert.ok(withEvidence.buffer.includes(Buffer.from('SHA-256 de la fuente del Informe')));
|
||||||
|
assert.ok(withEvidence.buffer.length > plain.buffer.length + photo.length);
|
||||||
|
assert.ok(!withEvidence.buffer.includes(Buffer.from('[Completar')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.9 keeps prior sealed PDF and prior company responses available for audit', () => {
|
||||||
|
const pdf = readFileSync(resolve(process.cwd(), 'src/inspection-reports/inspection-act-pdf.service.ts'), 'utf8');
|
||||||
|
const controller = readFileSync(resolve(process.cwd(), 'src/act-administration/act-administration.controller.ts'), 'utf8');
|
||||||
|
assert.match(pdf, /inspection_act_consolidated_pdf_revisions/);
|
||||||
|
const word = readFileSync(resolve(process.cwd(), 'src/inspection-reports/inspection-report-word.service.ts'), 'utf8');
|
||||||
|
assert.match(word, /inspection_report_consolidated_word_revisions/);
|
||||||
|
assert.match(pdf, /ON CONFLICT \(act_id,template_version\) DO NOTHING/);
|
||||||
|
assert.match(controller, /responseContent\(responseId\)/);
|
||||||
|
assert.doesNotMatch(controller, /@Post\('responses'\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.9 renders WebP field photographs in both documents without changing the source hash', async () => {
|
||||||
|
const original = await sharp(photo).webp().toBuffer();
|
||||||
|
const originalSha = digest(original);
|
||||||
|
const display = await renderableInspectionImage(original);
|
||||||
|
assert.ok(display.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])));
|
||||||
|
assert.equal(digest(original), originalSha);
|
||||||
|
const pdf = await buildInspectionActPdf(sealed, [{ ...evidence, sha256: originalSha, buffer: display }]);
|
||||||
|
assert.ok(pdf.buffer.subarray(0, 8).equals(Buffer.from('%PDF-1.4')));
|
||||||
|
const word = buildInspectionReportWord({ code: 'INF-WEBP', title: 'Informe', generatedAt: new Date(),
|
||||||
|
frozenSha256: 'c'.repeat(64), frozenSnapshot: { sealedAct: sealed }, photos: [{ ...evidence, sha256: originalSha, buffer: display }] });
|
||||||
|
assert.ok(word.buffer.includes(display));
|
||||||
|
assert.ok(word.buffer.includes(Buffer.from(originalSha)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.9 built CommonJS runtime renders signed PDF and WebP evidence', async () => {
|
||||||
|
const builtPdf = resolve(process.cwd(), 'dist/inspection-reports/inspection-act-pdf-builder.js');
|
||||||
|
const builtImage = resolve(process.cwd(), 'dist/inspection-reports/inspection-document-images.js');
|
||||||
|
if (!existsSync(builtPdf) || !existsSync(builtImage)) return;
|
||||||
|
const requireCjs = createRequire(resolve(process.cwd(), 'package.json'));
|
||||||
|
const runtimePdf = requireCjs(builtPdf) as { buildInspectionActPdf: typeof buildInspectionActPdf };
|
||||||
|
const runtimeImage = requireCjs(builtImage) as { renderableInspectionImage: typeof renderableInspectionImage };
|
||||||
|
const webp = await sharp(photo).webp().toBuffer();
|
||||||
|
const rendered = await runtimeImage.renderableInspectionImage(webp);
|
||||||
|
const pdf = await runtimePdf.buildInspectionActPdf(sealed, [{ ...evidence, buffer: rendered, sha256: digest(webp) }]);
|
||||||
|
assert.ok(pdf.buffer.subarray(0, 8).equals(Buffer.from('%PDF-1.4')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.9 revision migration archives both first document versions before serving the corrected template', () => {
|
||||||
|
const migration = readFileSync(resolve(process.cwd(),
|
||||||
|
'src/database/migrations/1790135400000-f6-9-consolidated-document-revisions.ts'), 'utf8');
|
||||||
|
assert.match(migration, /SELECT act_id,1,stored_name,original_name,size_bytes,sha256,generated_at/);
|
||||||
|
assert.match(migration, /SELECT report_id,1,stored_name,original_name,size_bytes,sha256,generated_at/);
|
||||||
|
assert.match(migration, /PRIMARY KEY \(act_id,template_version\)/);
|
||||||
|
assert.match(migration, /PRIMARY KEY \(report_id,template_version\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Acta ignores standalone field photos while the technical Informe remains independent', async () => {
|
||||||
|
const other = await sharp(photo).resize(75).png().toBuffer();
|
||||||
|
const unlinked = { id: 'photo-other-asset', assetId: 'asset-2', title: 'Otra instalación', sha256: digest(other), buffer: other };
|
||||||
|
const withUnlinked = await buildInspectionActPdf(sealed, [evidence, unlinked]);
|
||||||
|
const linkedOnly = await buildInspectionActPdf(sealed, [evidence]);
|
||||||
|
assert.ok(Math.abs(withUnlinked.buffer.length - linkedOnly.buffer.length) < 500);
|
||||||
|
const word = buildInspectionReportWord({ code: 'INF-OTHER', title: 'Informe', generatedAt: new Date(),
|
||||||
|
frozenSha256: 'c'.repeat(64), frozenSnapshot: { sealedAct: sealed }, photos: [evidence, unlinked] });
|
||||||
|
assert.ok(word.buffer.includes(Buffer.from('OTRAS INSTALACIONES INSPECCIONADAS')));
|
||||||
|
assert.ok(word.buffer.includes(Buffer.from('word/media/photo-2.png')));
|
||||||
|
assert.ok(word.buffer.includes(other));
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# F6.10 · Acta PDF completa y autoexplicativa
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Ordenar el Acta consolidada para que pueda comprenderse sin consultar el sistema y reforzar la explicación de cada Hallazgo.
|
||||||
|
|
||||||
|
## Contenido documental
|
||||||
|
|
||||||
|
El PDF se organiza en siete bloques: identificación, contexto territorial y operativo, intervinientes, datos del Acta, Hallazgos, firmas/manifestaciones e integridad/cierre.
|
||||||
|
|
||||||
|
Cada Hallazgo congela y presenta el elemento afectado, tipo de instalación, ruta Departamento → Área → Yacimiento → Instalación/Subinstalación, denominación, constatación, referencia de catálogo u OTROS, criterio técnico disponible, base legal, gravedad, reincidencia/antecedente y fotografías vinculadas directamente al Hallazgo.
|
||||||
|
|
||||||
|
No se incorporan al Acta altas de campo, instalaciones ni fotografías sin Hallazgo. Los plazos y la urgencia continúan perteneciendo al Acta, no a los Hallazgos.
|
||||||
|
|
||||||
|
## Inmutabilidad
|
||||||
|
|
||||||
|
El snapshot de cierre sube a `DH-ACT-LIFECYCLE-V5` para congelar contexto territorial, operadora, inspectores, ruta técnica y metadatos de catálogo/reincidencia. El PDF consolidado pasa a plantilla documental v3, conservando disponibles las revisiones v1/v2 ya emitidas.
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
- API typecheck: OK
|
||||||
|
- API tests: 470/470
|
||||||
|
- API build: OK
|
||||||
|
- PDF de muestra: 3 páginas, lectura completa y fechas Mendoza en formato administrativo de 24 horas.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# F6.11 · GEDO y respuestas de Informes
|
||||||
|
|
||||||
|
## Regla funcional
|
||||||
|
|
||||||
|
GEDO no está integrado como respuesta automática. El Informe permanece en preparación hasta que un usuario carga manualmente el identificador IF, la fecha y el PDF oficial emitido por GEDO.
|
||||||
|
|
||||||
|
La oficialización documental no crea una respuesta de empresa ni activa un vencimiento por sí sola.
|
||||||
|
|
||||||
|
## Flujo
|
||||||
|
|
||||||
|
1. El Acta sellada origina un INF editable.
|
||||||
|
2. El usuario carga manualmente IF + PDF oficial GEDO.
|
||||||
|
3. El Informe queda oficializado e inmutable en su contenido técnico.
|
||||||
|
4. Desde el Informe se define un único vencimiento de respuesta para su Acta.
|
||||||
|
5. Ese vencimiento se proyecta a todos los Hallazgos no anulados del Acta.
|
||||||
|
6. Las respuestas de empresa se registran después de la oficialización y quedan relacionadas con `report_id` + `act_id`.
|
||||||
|
7. Cada respuesta puede incluir fecha de recepción, detalle, compromiso, contacto y PDF.
|
||||||
|
|
||||||
|
Los vencimientos y respuestas históricas permanecen append-only y no se reescriben para completar relaciones nuevas.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# F6.16 · Mapa GPS puntual
|
||||||
|
|
||||||
|
La vista Mapa representa exclusivamente ubicaciones puntuales GPS.
|
||||||
|
|
||||||
|
- Se retiran temporalmente filtros, buscador y activación de capas.
|
||||||
|
- Se muestran siempre Inventario GPS, Yacimientos, Empresas, Actas y Hallazgos disponibles.
|
||||||
|
- El render usa marcadores DOM de MapLibre en vez de capas GeoJSON, evitando depender del procesamiento del worker para visualizar puntos.
|
||||||
|
- Las coordenadas siguen siendo las registradas; si varios registros comparten GPS, sólo se desplazan visualmente algunos píxeles para poder distinguirlos.
|
||||||
|
- El mapa se encuadra automáticamente sobre todos los puntos válidos.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# F6.17 · Tablas ordenables
|
||||||
|
|
||||||
|
La WEB utiliza un único componente `SortableTable` para las 28 tablas activas.
|
||||||
|
|
||||||
|
- Las cabeceras con texto alternan orden ascendente y descendente.
|
||||||
|
- Las columnas sin título (acciones) no son ordenables.
|
||||||
|
- El comparador reconoce texto, números y fechas visibles.
|
||||||
|
- El estado del orden se conserva mientras la tabla permanezca montada.
|
||||||
|
- El patrón aplica también a tablas de detalle, historial, auditoría, documentación e importaciones.
|
||||||
+13
-17
@@ -16,8 +16,6 @@ STAMP="$(date +%Y%m%d_%H%M%S)"
|
|||||||
BACKUP="$BACKUP_ROOT/GITEA_DEPLOY_${STAMP}"
|
BACKUP="$BACKUP_ROOT/GITEA_DEPLOY_${STAMP}"
|
||||||
STAGE="/root/dhv2-gitea-stage-${STAMP}"
|
STAGE="/root/dhv2-gitea-stage-${STAMP}"
|
||||||
LOG="/tmp/dhv2-gitea-deploy-${STAMP}.log"
|
LOG="/tmp/dhv2-gitea-deploy-${STAMP}.log"
|
||||||
API_TEST_IMAGE="dhv2-api:gitea-${STAMP}"
|
|
||||||
WEB_TEST_IMAGE="dhv2-web:gitea-${STAMP}"
|
|
||||||
PHASE="bootstrap"
|
PHASE="bootstrap"
|
||||||
PREV_SHA=""
|
PREV_SHA=""
|
||||||
TARGET_SHA=""
|
TARGET_SHA=""
|
||||||
@@ -32,7 +30,6 @@ cleanup() {
|
|||||||
set +e
|
set +e
|
||||||
git worktree remove --force "$STAGE" >/dev/null 2>&1 || true
|
git worktree remove --force "$STAGE" >/dev/null 2>&1 || true
|
||||||
rm -rf "$STAGE"
|
rm -rf "$STAGE"
|
||||||
docker image rm "$API_TEST_IMAGE" "$WEB_TEST_IMAGE" >/dev/null 2>&1 || true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
publish_status() {
|
publish_status() {
|
||||||
@@ -159,11 +156,13 @@ fi
|
|||||||
|
|
||||||
PREV_SHA="$(git rev-parse HEAD)"
|
PREV_SHA="$(git rev-parse HEAD)"
|
||||||
PHASE="fetch"
|
PHASE="fetch"
|
||||||
git fetch origin "$DEPLOY_REF"
|
git fetch origin "$DEPLOY_REF" main
|
||||||
TARGET_SHA="$(git rev-parse "origin/$DEPLOY_REF")"
|
TARGET_SHA="$(git rev-parse "origin/$DEPLOY_REF")"
|
||||||
|
MAIN_SHA="$(git rev-parse origin/main)"
|
||||||
|
|
||||||
echo "Actual: $PREV_SHA"
|
echo "Actual: $PREV_SHA"
|
||||||
echo "Objetivo: $TARGET_SHA"
|
echo "Objetivo: $TARGET_SHA"
|
||||||
|
echo "Main: $MAIN_SHA"
|
||||||
|
|
||||||
if [ "$TARGET_SHA" = "$PREV_SHA" ]; then
|
if [ "$TARGET_SHA" = "$PREV_SHA" ]; then
|
||||||
echo "Producción ya está en el commit autorizado."
|
echo "Producción ya está en el commit autorizado."
|
||||||
@@ -171,6 +170,13 @@ if [ "$TARGET_SHA" = "$PREV_SHA" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$TARGET_SHA" != "$MAIN_SHA" ]; then
|
||||||
|
echo "ERROR: deploy no coincide con main; se rechaza una promoción manual o incompleta."
|
||||||
|
echo "deploy: $TARGET_SHA"
|
||||||
|
echo "main: $MAIN_SHA"
|
||||||
|
false
|
||||||
|
fi
|
||||||
|
|
||||||
if ! git merge-base --is-ancestor "$PREV_SHA" "$TARGET_SHA"; then
|
if ! git merge-base --is-ancestor "$PREV_SHA" "$TARGET_SHA"; then
|
||||||
echo "ERROR: origin/$DEPLOY_REF no es fast-forward desde producción."
|
echo "ERROR: origin/$DEPLOY_REF no es fast-forward desde producción."
|
||||||
false
|
false
|
||||||
@@ -193,19 +199,9 @@ while IFS= read -r -d '' script; do
|
|||||||
done < <(find "$STAGE/scripts" -type f -name '*.sh' -print0)
|
done < <(find "$STAGE/scripts" -type f -name '*.sh' -print0)
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "========== TEST API CANDIDATA =========="
|
echo "========== PREFLIGHT DE DEPLOY =========="
|
||||||
docker build --target builder -t "$API_TEST_IMAGE" "$STAGE/api-v3" </dev/null
|
echo "Gitea Actions ya validó tests, migraciones e imágenes de producción."
|
||||||
docker run --rm \
|
echo "El VPS valida únicamente composición, scripts, backup, migraciones reales, recreación y health."
|
||||||
-v "$STAGE/api-v3/test:/app/test:ro" \
|
|
||||||
-v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
|
|
||||||
-v "$STAGE/docker-compose.yml:/docker-compose.yml:ro" \
|
|
||||||
-v "$STAGE/web-v2:/web-v2:ro" \
|
|
||||||
-v "$STAGE/android-app:/android-app:ro" \
|
|
||||||
"$API_TEST_IMAGE" npm test </dev/null
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "========== BUILD WEB CANDIDATA =========="
|
|
||||||
docker build -t "$WEB_TEST_IMAGE" "$STAGE/web-v2" </dev/null
|
|
||||||
|
|
||||||
PHASE="backup"
|
PHASE="backup"
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ COPY package*.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY tsconfig*.json vite.config.ts index.html ./
|
COPY tsconfig*.json vite.config.ts index.html ./
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
COPY scripts ./scripts
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
location = /assets/maplibre-gl-worker.mjs {
|
||||||
|
default_type application/javascript;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-web",
|
"name": "dhv2-web",
|
||||||
"version": "0.23.0-5",
|
"version": "0.23.0-13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dhv2-web",
|
"name": "dhv2-web",
|
||||||
"version": "0.23.0-5",
|
"version": "0.23.0-13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"maplibre-gl": "6.4.1",
|
"maplibre-gl": "6.4.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-web",
|
"name": "dhv2-web",
|
||||||
"version": "0.23.0-5",
|
"version": "0.23.0-13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build && node scripts/copy-maplibre-worker.mjs",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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)`);
|
||||||
@@ -18,7 +18,7 @@ const actSections: ProjectionSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Alcance y constatación',
|
title: 'Alcance y constatación',
|
||||||
description: 'Objeto de la actuación, descripción de lo actuado, observaciones e Inventarios inspeccionados con su ruta Instalación / Subinstalación.',
|
description: 'Objeto de la actuación, descripción de lo actuado y observaciones generales de la inspección.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Hallazgos',
|
title: 'Hallazgos',
|
||||||
@@ -34,7 +34,7 @@ const actSections: ProjectionSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Anexos',
|
title: 'Anexos',
|
||||||
description: 'Registro fotográfico y evidencias sólo si el formulario oficial exige incorporarlos al Acta; el modelo queda preparado para hacerlo sin alterar el dato fuente.',
|
description: 'Sólo evidencia vinculada a Hallazgos del Acta. Las fotos de inventario y altas de campo sin Hallazgos quedan fuera del documento.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
Children,
|
||||||
|
cloneElement,
|
||||||
|
isValidElement,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactElement,
|
||||||
|
type ReactNode,
|
||||||
|
type TableHTMLAttributes,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
type SortDirection = 'ascending' | 'descending';
|
||||||
|
type SortState = { column: number; direction: SortDirection } | null;
|
||||||
|
type ElementProps = Record<string, unknown> & { children?: ReactNode; colSpan?: number };
|
||||||
|
|
||||||
|
function textValue(node: ReactNode): string {
|
||||||
|
if (node == null || typeof node === 'boolean') return '';
|
||||||
|
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||||
|
if (Array.isArray(node)) return node.map(textValue).join(' ');
|
||||||
|
if (!isValidElement(node)) return '';
|
||||||
|
const props = node.props as ElementProps;
|
||||||
|
const explicit = props['data-sort-value'];
|
||||||
|
if (explicit != null) return String(explicit);
|
||||||
|
return textValue(props.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateValue(value: string): number | null {
|
||||||
|
const normalized = value.replace(/\s+/g, ' ').trim();
|
||||||
|
const local = normalized.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})(?:,?\s+(\d{1,2}):(\d{2}))?/);
|
||||||
|
if (local) {
|
||||||
|
const dayText = local[1]!;
|
||||||
|
const monthText = local[2]!;
|
||||||
|
const yearText = local[3]!;
|
||||||
|
const hourText = local[4] ?? '0';
|
||||||
|
const minuteText = local[5] ?? '0';
|
||||||
|
const year = Number(yearText.length === 2 ? `20${yearText}` : yearText);
|
||||||
|
return Date.UTC(year, Number(monthText) - 1, Number(dayText), Number(hourText), Number(minuteText));
|
||||||
|
}
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) {
|
||||||
|
const parsed = Date.parse(normalized);
|
||||||
|
return Number.isNaN(parsed) ? null : parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareValues(leftRaw: string, rightRaw: string): number {
|
||||||
|
const left = leftRaw.replace(/\s+/g, ' ').trim();
|
||||||
|
const right = rightRaw.replace(/\s+/g, ' ').trim();
|
||||||
|
if (!left && !right) return 0;
|
||||||
|
if (!left) return 1;
|
||||||
|
if (!right) return -1;
|
||||||
|
|
||||||
|
const leftDate = dateValue(left);
|
||||||
|
const rightDate = dateValue(right);
|
||||||
|
if (leftDate != null && rightDate != null) return leftDate - rightDate;
|
||||||
|
|
||||||
|
const numericPattern = /^[-+]?\d+(?:[.,]\d+)?$/;
|
||||||
|
if (numericPattern.test(left) && numericPattern.test(right)) {
|
||||||
|
return Number(left.replace(',', '.')) - Number(right.replace(',', '.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.localeCompare(right, 'es-AR', { numeric: true, sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowValue(row: ReactNode, column: number): string {
|
||||||
|
if (!isValidElement(row)) return '';
|
||||||
|
const cells = Children.toArray((row.props as ElementProps).children);
|
||||||
|
return textValue(cells[column]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortBody(body: ReactElement, sort: SortState): ReactElement {
|
||||||
|
if (!sort) return body;
|
||||||
|
const props = body.props as ElementProps;
|
||||||
|
const rows = Children.toArray(props.children).map((row, index) => ({ row, index }));
|
||||||
|
rows.sort((left, right) => {
|
||||||
|
const compared = compareValues(rowValue(left.row, sort.column), rowValue(right.row, sort.column));
|
||||||
|
if (compared === 0) return left.index - right.index;
|
||||||
|
return sort.direction === 'ascending' ? compared : -compared;
|
||||||
|
});
|
||||||
|
return cloneElement(body, undefined, rows.map((entry) => entry.row));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortableTable({ children, ...props }: TableHTMLAttributes<HTMLTableElement>) {
|
||||||
|
const [sort, setSort] = useState<SortState>(null);
|
||||||
|
|
||||||
|
const renderedChildren = useMemo(() => Children.toArray(children).map((child) => {
|
||||||
|
if (!isValidElement(child)) return child;
|
||||||
|
if (child.type === 'tbody') return sortBody(child, sort);
|
||||||
|
if (child.type !== 'thead') return child;
|
||||||
|
|
||||||
|
const headProps = child.props as ElementProps;
|
||||||
|
const rows = Children.toArray(headProps.children).map((row) => {
|
||||||
|
if (!isValidElement(row) || row.type !== 'tr') return row;
|
||||||
|
const rowProps = row.props as ElementProps;
|
||||||
|
const headers = Children.toArray(rowProps.children).map((header, column) => {
|
||||||
|
if (!isValidElement(header) || header.type !== 'th') return header;
|
||||||
|
const headerProps = header.props as ElementProps;
|
||||||
|
const label = textValue(headerProps.children).trim();
|
||||||
|
const sortable = Boolean(label) && headerProps['data-sortable'] !== false && (headerProps.colSpan ?? 1) === 1;
|
||||||
|
if (!sortable) return header;
|
||||||
|
const active = sort?.column === column;
|
||||||
|
const direction = active ? sort.direction : undefined;
|
||||||
|
const nextDirection: SortDirection = active && sort.direction === 'ascending' ? 'descending' : 'ascending';
|
||||||
|
const headerElement = header as ReactElement<Record<string, unknown>>;
|
||||||
|
return cloneElement(headerElement, {
|
||||||
|
'aria-sort': direction ?? 'none',
|
||||||
|
className: [String(headerProps.className ?? ''), 'sortable-th', active ? 'is-sorted' : ''].filter(Boolean).join(' '),
|
||||||
|
}, <button type="button" className="sortable-th-button" onClick={() => setSort({ column, direction: nextDirection })}>
|
||||||
|
<span>{headerProps.children}</span>
|
||||||
|
<span className="sort-indicator" aria-hidden="true">{active ? (sort.direction === 'ascending' ? '▲' : '▼') : '↕'}</span>
|
||||||
|
</button>);
|
||||||
|
});
|
||||||
|
return cloneElement(row, undefined, headers);
|
||||||
|
});
|
||||||
|
return cloneElement(child, undefined, rows);
|
||||||
|
}), [children, sort]);
|
||||||
|
|
||||||
|
return <table {...props}>{renderedChildren}</table>;
|
||||||
|
}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '0.23.0-5';
|
export const APP_VERSION = '0.23.0-13';
|
||||||
export const APP_PHASE = 'F6.8 · Offline seguro y Acta documental';
|
export const APP_PHASE = 'F6.17 · Tablas ordenables';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SortableTable } from '../../components/SortableTable';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { useAuth } from '../../auth/AuthContext';
|
import { useAuth } from '../../auth/AuthContext';
|
||||||
@@ -225,7 +226,7 @@ export function AssetDossierPanel({ assetId }: { assetId: string }) {
|
|||||||
|
|
||||||
<article className="panel dossier-wide">
|
<article className="panel dossier-wide">
|
||||||
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS</span><h2>Seguimiento del elemento</h2></div><Link className="text-link" to="/hallazgos">Ver bandeja general <Icon name="chevron" size={14} /></Link></div>
|
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS</span><h2>Seguimiento del elemento</h2></div><Link className="text-link" to="/hallazgos">Ver bandeja general <Icon name="chevron" size={14} /></Link></div>
|
||||||
{dossier.findings.length === 0 ? <EmptyState title="Sin hallazgos" text="Este elemento todavía no registra hallazgos." /> : <div className="table-scroll"><table><thead><tr><th>Hallazgo</th><th>Estado</th><th>Acta</th><th>Vencimiento empresa</th><th>Verificación</th><th /></tr></thead><tbody>{dossier.findings.map((finding) => <tr key={finding.id}><td><strong>{finding.title}</strong><small className="block-muted">{finding.code}</small></td><td><span className={`status-badge ${finding.status === 'OPEN' ? 'observed' : 'active'}`}>{findingStatusLabel(finding.status)}</span></td><td><Link className="text-link" to={`/inspecciones/actas/${finding.actId}`}>{finding.actCode}</Link></td><td>{formatDateOnly(finding.correctionDueOn)}</td><td>{formatDateOnly(finding.nextControlOn)}</td><td className="action-cell"><Link className="icon-button" to={`/hallazgos/${finding.id}`} aria-label={`Abrir ${finding.code}`}><Icon name="chevron" /></Link></td></tr>)}</tbody></table></div>}
|
{dossier.findings.length === 0 ? <EmptyState title="Sin hallazgos" text="Este elemento todavía no registra hallazgos." /> : <div className="table-scroll"><SortableTable><thead><tr><th>Hallazgo</th><th>Estado</th><th>Acta</th><th>Vencimiento empresa</th><th>Verificación</th><th /></tr></thead><tbody>{dossier.findings.map((finding) => <tr key={finding.id}><td><strong>{finding.title}</strong><small className="block-muted">{finding.code}</small></td><td><span className={`status-badge ${finding.status === 'OPEN' ? 'observed' : 'active'}`}>{findingStatusLabel(finding.status)}</span></td><td><Link className="text-link" to={`/inspecciones/actas/${finding.actId}`}>{finding.actCode}</Link></td><td>{formatDateOnly(finding.correctionDueOn)}</td><td>{formatDateOnly(finding.nextControlOn)}</td><td className="action-cell"><Link className="icon-button" to={`/hallazgos/${finding.id}`} aria-label={`Abrir ${finding.code}`}><Icon name="chevron" /></Link></td></tr>)}</tbody></SortableTable></div>}
|
||||||
</article>
|
</article>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SortableTable } from '../../components/SortableTable';
|
||||||
import { SearchableSelect } from '../../components/SearchableSelect';
|
import { SearchableSelect } from '../../components/SearchableSelect';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
@@ -121,7 +122,7 @@ export function AssetOperationalRelationsPanel({
|
|||||||
<div className="form-actions"><button className="button secondary" disabled={busy || !candidateId || reason.trim().length < 3}><Icon name="plus" />{busy ? 'Guardando…' : 'Vincular'}</button></div>
|
<div className="form-actions"><button className="button secondary" disabled={busy || !candidateId || reason.trim().length < 3}><Icon name="plus" />{busy ? 'Guardando…' : 'Vincular'}</button></div>
|
||||||
</form>}
|
</form>}
|
||||||
|
|
||||||
{relations.length === 0 ? <EmptyState title="Sin relaciones registradas" text={role === 'AREA' ? 'Todavía no hay organizaciones vinculadas con esta área.' : 'Todavía no hay áreas vinculadas con esta organización.'} /> : <div className="table-scroll"><table><thead><tr><th>{role === 'AREA' ? 'Organización' : 'Área'}</th><th>Rol</th><th>Vigencia</th><th>Motivo</th><th>Registros asignados</th><th>Estado</th><th /></tr></thead><tbody>{relations.map((relation) => {
|
{relations.length === 0 ? <EmptyState title="Sin relaciones registradas" text={role === 'AREA' ? 'Todavía no hay organizaciones vinculadas con esta área.' : 'Todavía no hay áreas vinculadas con esta organización.'} /> : <div className="table-scroll"><SortableTable><thead><tr><th>{role === 'AREA' ? 'Organización' : 'Área'}</th><th>Rol</th><th>Vigencia</th><th>Motivo</th><th>Registros asignados</th><th>Estado</th><th /></tr></thead><tbody>{relations.map((relation) => {
|
||||||
const counterpart = role === 'AREA' ? relation.company : relation.area;
|
const counterpart = role === 'AREA' ? relation.company : relation.area;
|
||||||
return <tr key={relation.id}>
|
return <tr key={relation.id}>
|
||||||
<td><strong className="table-primary">{counterpart.name}</strong><small className="cell-subtext">{counterpart.code} · {counterpart.typeName}</small></td>
|
<td><strong className="table-primary">{counterpart.name}</strong><small className="cell-subtext">{counterpart.code} · {counterpart.typeName}</small></td>
|
||||||
@@ -133,6 +134,6 @@ export function AssetOperationalRelationsPanel({
|
|||||||
? <div className="relation-end-inline"><input value={endReason} onChange={(event) => setEndReason(event.target.value)} placeholder="Motivo de finalización" minLength={3} maxLength={1000} /><button type="button" className="button danger-outline compact" disabled={busy || endReason.trim().length < 3} onClick={() => endRelation(relation.id)}>Confirmar</button><button type="button" className="button text compact" onClick={() => { setEndingId(null); setEndReason(''); }}>Cancelar</button></div>
|
? <div className="relation-end-inline"><input value={endReason} onChange={(event) => setEndReason(event.target.value)} placeholder="Motivo de finalización" minLength={3} maxLength={1000} /><button type="button" className="button danger-outline compact" disabled={busy || endReason.trim().length < 3} onClick={() => endRelation(relation.id)}>Confirmar</button><button type="button" className="button text compact" onClick={() => { setEndingId(null); setEndReason(''); }}>Cancelar</button></div>
|
||||||
: <button type="button" className="button danger-outline compact" onClick={() => { setEndingId(relation.id); setEndReason(''); }}>Finalizar</button>)}</td>
|
: <button type="button" className="button danger-outline compact" onClick={() => { setEndingId(relation.id); setEndReason(''); }}>Finalizar</button>)}</td>
|
||||||
</tr>;
|
</tr>;
|
||||||
})}</tbody></table></div>}
|
})}</tbody></SortableTable></div>}
|
||||||
</article>;
|
</article>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SortableTable } from '../../components/SortableTable';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
|
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
|
||||||
@@ -76,7 +77,7 @@ export function FieldBriefingPanel({ visit }: { visit: InspectionVisit }) {
|
|||||||
<div><strong>{act.actCode}</strong><span>{formatDate(act.occurredAt)} · {act.findings.length} hallazgo{act.findings.length === 1 ? '' : 's'}</span></div>
|
<div><strong>{act.actCode}</strong><span>{formatDate(act.occurredAt)} · {act.findings.length} hallazgo{act.findings.length === 1 ? '' : 's'}</span></div>
|
||||||
<span className={`status-badge ${stateClass(act.adminState)}`}>{stateLabel(act.adminState)}</span>
|
<span className={`status-badge ${stateClass(act.adminState)}`}>{stateLabel(act.adminState)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-scroll"><table>
|
<div className="table-scroll"><SortableTable>
|
||||||
<thead><tr><th>Hallazgo</th><th>Inventario</th><th>Gravedad</th><th>Control</th><th /></tr></thead>
|
<thead><tr><th>Hallazgo</th><th>Inventario</th><th>Gravedad</th><th>Control</th><th /></tr></thead>
|
||||||
<tbody>{act.findings.map((finding) => <tr key={finding.id}>
|
<tbody>{act.findings.map((finding) => <tr key={finding.id}>
|
||||||
<td><Link className="history-asset-link" to={`/hallazgos/${finding.id}`}><strong>{finding.title}</strong><small>{finding.code} · {finding.status}</small></Link></td>
|
<td><Link className="history-asset-link" to={`/hallazgos/${finding.id}`}><strong>{finding.title}</strong><small>{finding.code} · {finding.status}</small></Link></td>
|
||||||
@@ -85,7 +86,7 @@ export function FieldBriefingPanel({ visit }: { visit: InspectionVisit }) {
|
|||||||
<td>{finding.nextControlOn ? formatDateOnly(finding.nextControlOn) : 'A definir'}</td>
|
<td>{finding.nextControlOn ? formatDateOnly(finding.nextControlOn) : 'A definir'}</td>
|
||||||
<td className="action-cell"><Link className="button secondary compact" to={`/seguimiento-actas/${act.actId}`}>Ver Acta</Link></td>
|
<td className="action-cell"><Link className="button secondary compact" to={`/seguimiento-actas/${act.actId}`}>Ver Acta</Link></td>
|
||||||
</tr>)}</tbody>
|
</tr>)}</tbody>
|
||||||
</table></div>
|
</SortableTable></div>
|
||||||
<div className="table-summary">
|
<div className="table-summary">
|
||||||
<span>Plazo empresa: <strong>{act.responseDueOn ? formatDateOnly(act.responseDueOn) : 'sin definir'}</strong></span>
|
<span>Plazo empresa: <strong>{act.responseDueOn ? formatDateOnly(act.responseDueOn) : 'sin definir'}</strong></span>
|
||||||
<span>Respuesta: <strong>{act.responseReceivedOn ? formatDateOnly(act.responseReceivedOn) : 'pendiente'}</strong></span>
|
<span>Respuesta: <strong>{act.responseReceivedOn ? formatDateOnly(act.responseReceivedOn) : 'pendiente'}</strong></span>
|
||||||
|
|||||||
@@ -1,149 +1,80 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
|
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
|
||||||
import { Icon } from '../../components/Icon';
|
|
||||||
import {
|
import {
|
||||||
getAssetMediaBlob,
|
|
||||||
getInspectionFindingEvidenceBlob,
|
getInspectionFindingEvidenceBlob,
|
||||||
listInspectionActFieldMedia,
|
|
||||||
listInspectionFindingEvidence,
|
listInspectionFindingEvidence,
|
||||||
listInspectionFindings,
|
listInspectionFindings,
|
||||||
} from '../../lib/api';
|
} from '../../lib/api';
|
||||||
import type { InspectionActFieldMedia, InspectionFindingEvidence } from '../../lib/api';
|
import type { InspectionFinding, InspectionFindingEvidence } from '../../lib/api';
|
||||||
import { formatDate } from '../../lib/format';
|
import { formatDate } from '../../lib/format';
|
||||||
|
|
||||||
type ActPhoto = {
|
type FindingWithPhotos = { finding: InspectionFinding; photos: InspectionFindingEvidence[] };
|
||||||
findingId: string;
|
|
||||||
findingCode: string;
|
|
||||||
findingTitle: string;
|
|
||||||
evidence: InspectionFindingEvidence;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AssetPhoto = InspectionActFieldMedia;
|
function Photo({ id, title, caption, load }: { id: string; title: string; caption: string; load: (id: string) => Promise<Blob> }) {
|
||||||
|
|
||||||
function PhotoThumb({ photo }: { photo: ActPhoto }) {
|
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
let objectUrl = '';
|
let objectUrl = '';
|
||||||
getInspectionFindingEvidenceBlob(photo.evidence.id)
|
load(id).then((blob) => {
|
||||||
.then((blob) => {
|
if (!active) return;
|
||||||
if (!active) return;
|
objectUrl = URL.createObjectURL(blob);
|
||||||
objectUrl = URL.createObjectURL(blob);
|
setUrl(objectUrl);
|
||||||
setUrl(objectUrl);
|
}).catch(() => undefined);
|
||||||
})
|
return () => { active = false; if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
||||||
.catch(() => undefined);
|
}, [id, load]);
|
||||||
return () => {
|
return <figure className="act-finding-photo">
|
||||||
active = false;
|
{url ? <a href={url} target="_blank" rel="noopener noreferrer" aria-label={`Ver foto ${title}`}><img src={url} alt={title} /></a> : <span className="media-preview-loading">Cargando foto…</span>}
|
||||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
<figcaption>{caption}</figcaption>
|
||||||
};
|
</figure>;
|
||||||
}, [photo.evidence.id]);
|
|
||||||
|
|
||||||
return <article className="act-photo-card">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="act-photo-preview"
|
|
||||||
onClick={() => url && window.open(url, '_blank', 'noopener,noreferrer')}
|
|
||||||
aria-label={`Ver foto ${photo.findingCode}`}
|
|
||||||
>
|
|
||||||
{url
|
|
||||||
? <img src={url} alt={photo.evidence.title || `${photo.findingCode} · ${photo.findingTitle}`} />
|
|
||||||
: <span className="media-preview-loading"><span className="spinner" /></span>}
|
|
||||||
</button>
|
|
||||||
<div className="act-photo-copy">
|
|
||||||
<span className="eyebrow">{photo.findingCode}</span>
|
|
||||||
<strong>{photo.findingTitle}</strong>
|
|
||||||
<small>{formatDate(photo.evidence.capturedAt || photo.evidence.createdAt)}</small>
|
|
||||||
{photo.evidence.latitude != null && photo.evidence.longitude != null && <small>
|
|
||||||
GPS {photo.evidence.latitude.toFixed(6)}, {photo.evidence.longitude.toFixed(6)}
|
|
||||||
</small>}
|
|
||||||
</div>
|
|
||||||
</article>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssetPhotoThumb({ photo }: { photo: AssetPhoto }) {
|
function Finding({ item }: { item: FindingWithPhotos }) {
|
||||||
const [url, setUrl] = useState('');
|
const { finding, photos } = item;
|
||||||
|
const hierarchy = finding.asset.hierarchy;
|
||||||
useEffect(() => {
|
return <article className="act-finding-record">
|
||||||
let active = true;
|
<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>
|
||||||
let objectUrl = '';
|
{hierarchy && <div className="responsible-summary act-finding-context">
|
||||||
getAssetMediaBlob(photo.id)
|
<div><small>Departamento</small><strong>{hierarchy.department?.name ?? '—'}</strong></div>
|
||||||
.then((blob) => {
|
<div><small>Área</small><strong>{hierarchy.area?.name ?? '—'}</strong></div>
|
||||||
if (!active) return;
|
<div><small>Yacimiento</small><strong>{hierarchy.yacimiento?.name ?? '—'}</strong></div>
|
||||||
objectUrl = URL.createObjectURL(blob);
|
<div><small>Empresa</small><strong>{hierarchy.company?.name ?? '—'}</strong></div>
|
||||||
setUrl(objectUrl);
|
<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>
|
||||||
.catch(() => undefined);
|
</div>}
|
||||||
return () => {
|
<p className="inspection-finding-description"><strong>Constatación:</strong> {finding.description}</p>
|
||||||
active = false;
|
{finding.legalBasis && <p><strong>Normativa:</strong> {finding.legalBasis}</p>}
|
||||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
{finding.severity != null && <p>Gravedad {finding.severity}/10</p>}
|
||||||
};
|
{photos.length > 0 && <div className="act-finding-photos">
|
||||||
}, [photo.id]);
|
{photos.map((photo) => <Photo key={photo.id} id={photo.id} title={photo.title || finding.title} caption={`Hallazgo · ${formatDate(photo.capturedAt || photo.createdAt)}${photo.latitude != null && photo.longitude != null ? ` · GPS ${photo.latitude.toFixed(6)}, ${photo.longitude.toFixed(6)}` : ''}`} load={getInspectionFindingEvidenceBlob} />)}
|
||||||
|
</div>}
|
||||||
return <article className="act-photo-card">
|
{photos.length === 0 && <small className="muted">Sin fotografías vinculadas al hallazgo.</small>}
|
||||||
<button type="button" className="act-photo-preview" onClick={() => url && window.open(url, '_blank', 'noopener,noreferrer')} aria-label={`Ver foto ${photo.assetCode}`}>
|
|
||||||
{url ? <img src={url} alt={photo.title || photo.assetName} /> : <span className="media-preview-loading"><span className="spinner" /></span>}
|
|
||||||
</button>
|
|
||||||
<div className="act-photo-copy">
|
|
||||||
<span className="eyebrow">INVENTARIO · {photo.assetCode}</span>
|
|
||||||
<strong>{photo.assetName}</strong>
|
|
||||||
<small>{formatDate(photo.fieldCapturedAt || photo.capturedAt || photo.createdAt)}</small>
|
|
||||||
{photo.latitude != null && photo.longitude != null && <small>GPS {photo.latitude.toFixed(6)}, {photo.longitude.toFixed(6)}</small>}
|
|
||||||
</div>
|
|
||||||
</article>;
|
</article>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InspectionActMediaPanel({ actId }: { actId: string }) {
|
export function InspectionActMediaPanel({ actId }: { actId: string }) {
|
||||||
const [photos, setPhotos] = useState<ActPhoto[]>([]);
|
const [items, setItems] = useState<FindingWithPhotos[]>([]);
|
||||||
const [assetPhotos, setAssetPhotos] = useState<AssetPhoto[]>([]);
|
|
||||||
const [findingCount, setFindingCount] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
setLoading(true);
|
setLoading(true); setError('');
|
||||||
setError('');
|
listInspectionFindings(actId)
|
||||||
Promise.all([
|
.then(async (findings) => {
|
||||||
listInspectionFindings(actId),
|
const records = await Promise.all(findings.map(async (finding) => ({
|
||||||
listInspectionActFieldMedia(actId).catch(() => []),
|
finding,
|
||||||
])
|
photos: (await listInspectionFindingEvidence(finding.id)).filter((evidence) => evidence.kind === 'PHOTO' && evidence.purpose === 'OBSERVATION'),
|
||||||
.then(async ([findings, fieldMedia]) => {
|
})));
|
||||||
const evidenceByFinding = await Promise.all(
|
if (active) setItems(records);
|
||||||
findings.map(async (finding) => ({
|
|
||||||
finding,
|
|
||||||
evidence: await listInspectionFindingEvidence(finding.id),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
if (!active) return;
|
|
||||||
setFindingCount(findings.length);
|
|
||||||
setPhotos(evidenceByFinding.flatMap(({ finding, evidence }) =>
|
|
||||||
evidence.filter((item) => item.kind === 'PHOTO').map((item) => ({
|
|
||||||
findingId: finding.id, findingCode: finding.code, findingTitle: finding.title, evidence: item,
|
|
||||||
})),
|
|
||||||
));
|
|
||||||
setAssetPhotos(fieldMedia.filter((item) => item.kind === 'PHOTO'));
|
|
||||||
})
|
})
|
||||||
.catch((requestError) => active && setError(errorMessage(requestError)))
|
.catch((requestError) => active && setError(errorMessage(requestError)))
|
||||||
.finally(() => active && setLoading(false));
|
.finally(() => active && setLoading(false));
|
||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, [actId]);
|
}, [actId]);
|
||||||
|
|
||||||
return <section className="panel act-media-panel">
|
return <section className="panel act-media-panel">
|
||||||
<div className="panel-heading">
|
<div className="panel-heading"><div><h2>Hallazgos del Acta</h2><p className="section-copy">El Acta muestra únicamente Hallazgos y la evidencia fotográfica vinculada a cada uno.</p></div><span className="count-pill">{items.length}</span></div>
|
||||||
<div>
|
|
||||||
<span className="eyebrow">REGISTRO DE CAMPO</span>
|
|
||||||
<h2>Fotos y Hallazgos</h2>
|
|
||||||
<p className="section-copy">Las fotografías del Acta se muestran directamente, vinculadas al Hallazgo que documentan.</p>
|
|
||||||
</div>
|
|
||||||
<span className="count-pill">{photos.length + assetPhotos.length} foto{photos.length + assetPhotos.length === 1 ? '' : 's'} · {findingCount} hallazgo{findingCount === 1 ? '' : 's'}</span>
|
|
||||||
</div>
|
|
||||||
{error && <Alert>{error}</Alert>}
|
{error && <Alert>{error}</Alert>}
|
||||||
{loading ? <LoadingBlock label="Cargando registro fotográfico…" /> : <>
|
{loading ? <LoadingBlock label="Cargando hallazgos y fotografías…" /> : items.length ?
|
||||||
{photos.length === 0 && assetPhotos.length === 0 && <EmptyState title="Sin fotografías" text="Esta Acta todavía no tiene fotografías sincronizadas." />}
|
<div className="act-finding-list">{items.map((item) => <Finding key={item.finding.id} item={item} />)}</div> :
|
||||||
{photos.length > 0 && <div className="act-media-group"><div className="subsection-heading"><div><span className="eyebrow">HALLAZGOS</span><h4>Evidencia fotográfica</h4></div><span>{photos.length}</span></div><div className="act-photo-grid">{photos.map((photo) => <PhotoThumb key={photo.evidence.id} photo={photo} />)}</div></div>}
|
<EmptyState title="Sin hallazgos" text="Esta Acta no contiene hallazgos sincronizados." />}
|
||||||
{assetPhotos.length > 0 && <div className="act-media-group"><div className="subsection-heading"><div><span className="eyebrow">INVENTARIO DE LA INSPECCIÓN</span><h4>Fotos tomadas durante esta inspección</h4></div><span>{assetPhotos.length}</span></div><div className="act-photo-grid">{assetPhotos.map((photo) => <AssetPhotoThumb key={photo.id} photo={photo} />)}</div></div>}
|
|
||||||
</>}
|
|
||||||
{(photos.length > 0 || assetPhotos.length > 0) && <div className="act-media-footnote"><Icon name="camera" /><span>Seleccioná una foto para verla a tamaño completo.</span></div>}
|
|
||||||
</section>;
|
</section>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SortableTable } from '../../components/SortableTable';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { useAuth } from '../../auth/AuthContext';
|
import { useAuth } from '../../auth/AuthContext';
|
||||||
@@ -37,7 +38,7 @@ export function InspectionActsPanel({ visit }: { visit: InspectionVisit }) {
|
|||||||
{error && <Alert>{error}</Alert>}
|
{error && <Alert>{error}</Alert>}
|
||||||
{loading ? <LoadingBlock label="Cargando actas…" /> : acts.length === 0
|
{loading ? <LoadingBlock label="Cargando actas…" /> : acts.length === 0
|
||||||
? <EmptyState title="Sin actas sincronizadas" text={visit.status === 'IN_PROGRESS' ? 'El inspector puede crear la primera acta desde la APK.' : 'Aparecerán aquí a medida que el inspector genere actas desde la APK.'} />
|
? <EmptyState title="Sin actas sincronizadas" text={visit.status === 'IN_PROGRESS' ? 'El inspector puede crear la primera acta desde la APK.' : 'Aparecerán aquí a medida que el inspector genere actas desde la APK.'} />
|
||||||
: <div className="table-panel inspection-acts-table"><div className="table-summary"><strong>Actas de la inspección</strong><span>Numeración oficial global por año</span></div><div className="table-scroll"><table><thead><tr><th>Acta</th><th>Estado</th><th>Fecha</th><th>Registros</th><th>Hallazgos</th><th>Versión</th><th /></tr></thead><tbody>{acts.map((act) => <tr key={act.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{act.title}</strong><small>{act.code}</small></div></div></td><td><span className={`status-badge ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span></td><td>{formatDate(act.occurredAt)}</td><td>{act.assetCount}</td><td>{act.findingCount}</td><td>v{act.currentVersion}</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>}
|
: <div className="table-panel inspection-acts-table"><div className="table-summary"><strong>Actas de la inspección</strong><span>Numeración oficial global por año</span></div><div className="table-scroll"><SortableTable><thead><tr><th>Acta</th><th>Estado</th><th>Fecha</th><th>Registros</th><th>Hallazgos</th><th>Versión</th><th /></tr></thead><tbody>{acts.map((act) => <tr key={act.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{act.title}</strong><small>{act.code}</small></div></div></td><td><span className={`status-badge ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span></td><td>{formatDate(act.occurredAt)}</td><td>{act.assetCount}</td><td>{act.findingCount}</td><td>v{act.currentVersion}</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></SortableTable></div></div>}
|
||||||
</section>}
|
</section>}
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,17 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useAuth } from '../../auth/AuthContext';
|
import { useAuth } from '../../auth/AuthContext';
|
||||||
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
|
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
|
||||||
import { Icon } from '../../components/Icon';
|
|
||||||
import { getInspectionSignatureBlob } from '../../lib/api';
|
import { getInspectionSignatureBlob } from '../../lib/api';
|
||||||
import type { InspectionActSignature } from '../../lib/api';
|
import type { InspectionActSignature } from '../../lib/api';
|
||||||
import {
|
import { getInspectionClosureF4, type InspectionActF4, type InspectionClosureF4 } from '../../lib/inspectionActF4Api';
|
||||||
getInspectionClosureF4,
|
|
||||||
type InspectionActF4,
|
|
||||||
type InspectionClosureF4,
|
|
||||||
} from '../../lib/inspectionActF4Api';
|
|
||||||
import { formatDate } from '../../lib/format';
|
import { formatDate } from '../../lib/format';
|
||||||
|
|
||||||
function signatureStatusLabel(value: InspectionActSignature['status']): string {
|
function signatureOutcome(signature: InspectionActSignature): string {
|
||||||
if (value === 'SIGNED') return 'Firmada';
|
if (signature.status === 'REFUSED') return `Se negó a firmar${signature.reason ? ` · ${signature.reason}` : ''}`;
|
||||||
if (value === 'REFUSED') return 'Se negó a firmar';
|
if (signature.status !== 'SIGNED') return 'No firmó';
|
||||||
return 'Ausente';
|
if (signature.signerType === 'INSPECTOR') return 'Firmó como inspector/a';
|
||||||
}
|
if (signature.companyManifestation === 'DISSENT') return `Firmó en disconformidad${signature.companyStatement ? ` · ${signature.companyStatement}` : ''}`;
|
||||||
|
return 'Firmó en conformidad';
|
||||||
function signerTypeLabel(value: InspectionActSignature['signerType']): string {
|
|
||||||
return value === 'INSPECTOR' ? 'Inspector/a' : 'Responsable de la empresa';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) {
|
export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) {
|
||||||
@@ -27,25 +20,11 @@ export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) {
|
|||||||
const [closure, setClosure] = useState<InspectionClosureF4 | null>(null);
|
const [closure, setClosure] = useState<InspectionClosureF4 | null>(null);
|
||||||
const [loading, setLoading] = useState(canRead);
|
const [loading, setLoading] = useState(canRead);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canRead) return;
|
if (!canRead) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
getInspectionClosureF4(act.id)
|
getInspectionClosureF4(act.id).then(setClosure).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
||||||
.then(setClosure)
|
|
||||||
.catch((requestError) => setError(errorMessage(requestError)))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [act.id, canRead]);
|
}, [act.id, canRead]);
|
||||||
|
|
||||||
const inspectorSignatures = useMemo(
|
|
||||||
() => closure?.signatures.filter((item) => item.signerType === 'INSPECTOR') ?? [],
|
|
||||||
[closure],
|
|
||||||
);
|
|
||||||
const companyOutcome = useMemo(
|
|
||||||
() => closure?.signatures.find((item) => item.signerType === 'COMPANY_RESPONSIBLE') ?? null,
|
|
||||||
[closure],
|
|
||||||
);
|
|
||||||
|
|
||||||
const viewSignature = async (signature: InspectionActSignature) => {
|
const viewSignature = async (signature: InspectionActSignature) => {
|
||||||
const tab = window.open('about:blank', '_blank');
|
const tab = window.open('about:blank', '_blank');
|
||||||
if (tab) tab.opener = null;
|
if (tab) tab.opener = null;
|
||||||
@@ -55,53 +34,19 @@ export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) {
|
|||||||
if (tab) tab.location.href = url;
|
if (tab) tab.location.href = url;
|
||||||
else window.open(url, '_blank', 'noopener,noreferrer');
|
else window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
} catch (requestError) {
|
} catch (requestError) { tab?.close(); setError(errorMessage(requestError)); }
|
||||||
tab?.close();
|
|
||||||
setError(errorMessage(requestError));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!canRead) return null;
|
if (!canRead) return null;
|
||||||
if (loading || !closure) return <section className="panel inspection-closure-panel"><LoadingBlock label="Cargando cierre del acta…" /></section>;
|
if (loading || !closure) return <section className="panel"><LoadingBlock label="Cargando firmas…" /></section>;
|
||||||
|
return <section className="panel act-signatures-panel">
|
||||||
const constanciasCompletas = inspectorSignatures.length > 0 && Boolean(companyOutcome);
|
<div className="panel-heading"><div><h2>Participantes y firmas</h2></div></div>
|
||||||
const isSealed = act.status === 'SEALED' || act.status === 'CLOSED';
|
|
||||||
const isLocked = act.status === 'LOCKED' || act.status === 'READY';
|
|
||||||
const lifecycleLabel = isSealed
|
|
||||||
? 'Acta sellada'
|
|
||||||
: isLocked
|
|
||||||
? 'Esperando manifestación'
|
|
||||||
: act.status === 'CANCELLED'
|
|
||||||
? 'Acta cancelada'
|
|
||||||
: 'Borrador en campo';
|
|
||||||
const lifecycleClass = isSealed ? 'active' : isLocked ? 'observed' : act.status === 'CANCELLED' ? 'inactive' : 'pending';
|
|
||||||
|
|
||||||
return <section className="panel inspection-closure-panel">
|
|
||||||
<div className="panel-heading"><div><span className="eyebrow">CIERRE DEL ACTA · SÓLO LECTURA</span><h2>Responsable, firmas y sellado</h2><p className="section-copy">Al bloquearse, el contenido del Acta queda inmutable. La Inspección puede continuar y generar otras Actas.</p></div><span className={`status-badge large ${lifecycleClass}`}>{lifecycleLabel}</span></div>
|
|
||||||
|
|
||||||
{error && <Alert>{error}</Alert>}
|
{error && <Alert>{error}</Alert>}
|
||||||
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>El dashboard no modifica el Acta.</strong> El bloqueo y la firma del Inspector se realizan desde la APK. La manifestación de la empresa puede completarse en campo o mediante el enlace seguro posterior.</p></div>
|
{closure.responsible && <p><strong>Representante de la empresa:</strong> {closure.responsible.fullName ?? 'No estuvo presente'}{closure.responsible.documentNumber ? ` · ${closure.responsible.documentType} ${closure.responsible.documentNumber}` : ''}{closure.responsible.position ? ` · ${closure.responsible.position}` : ''}</p>}
|
||||||
|
{closure.signatures.length ? <div className="act-signer-list">{closure.signatures.map((signature) => <div key={signature.id} className="act-signer-row">
|
||||||
<div className="closure-step-grid">
|
<div><strong>{signature.signerName}</strong><small>{signature.signerType === 'INSPECTOR' ? 'Inspector/a' : 'Representante de la empresa'} · {formatDate(signature.signedAt ?? signature.createdAt)}</small><p>{signatureOutcome(signature)}</p></div>
|
||||||
<div className={closure.responsible ? 'complete' : ''}><span>1</span><strong>Responsable</strong><small>{closure.responsible ? 'Registrado' : 'Pendiente'}</small></div>
|
{signature.status === 'SIGNED' && <button type="button" className="button secondary" onClick={() => void viewSignature(signature)}>Ver firma</button>}
|
||||||
<div className={closure.closure?.isCurrent ? 'complete' : ''}><span>2</span><strong>Bloqueo</strong><small>{closure.closure?.isCurrent ? 'Contenido inmutable' : 'Pendiente'}</small></div>
|
</div>)}</div> : <p className="muted">Las firmas todavía no se registraron.</p>}
|
||||||
<div className={constanciasCompletas ? 'complete' : ''}><span>3</span><strong>Manifestaciones</strong><small>{inspectorSignatures.length} inspector · {companyOutcome ? 'empresa resuelta' : 'empresa pendiente'}</small></div>
|
{(act.status === 'SEALED' || act.status === 'CLOSED') && <p className="act-closed-date">Acta firmada y cerrada {formatDate(closure.act.sealedAt ?? closure.act.closedAt)}</p>}
|
||||||
<div className={isSealed ? 'complete' : ''}><span>4</span><strong>Sellado</strong><small>{isSealed ? 'Definitivo' : 'Pendiente'}</small></div>
|
{closure.closure?.finalSha256 && <details className="act-integrity-details"><summary>Verificar integridad del Acta</summary><p>SHA-256 del cierre: <code>{closure.closure.finalSha256}</code></p></details>}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="responsible-summary">
|
|
||||||
<div><small>Urgencia</small><strong>{closure.act.urgency === 'URGENT' ? 'Urgente' : closure.act.urgency === 'NON_URGENT' ? 'No urgente' : 'Pendiente de cierre'}</strong></div>
|
|
||||||
<div><small>Plazo configurado</small><strong>{closure.act.deadlineDays ? `${closure.act.deadlineDays} días ${closure.act.deadlineDayType === 'BUSINESS' ? 'hábiles' : 'corridos'}` : 'Pendiente'}</strong></div>
|
|
||||||
<div><small>Inicio del plazo</small><strong>{closure.act.deadlineBaseAt ? formatDate(closure.act.deadlineBaseAt) : 'Pendiente de evento válido'}</strong></div>
|
|
||||||
<div><small>Vencimiento</small><strong>{closure.act.deadlineAt ? formatDate(closure.act.deadlineAt) : 'Todavía no iniciado'}</strong></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{closure.responsible && <div className="responsible-summary"><div><small>Situación</small><strong>{closure.responsible.attendanceStatus === 'PRESENT' ? 'Presente' : 'Ausente'}</strong></div><div><small>Responsable</small><strong>{closure.responsible.fullName ?? 'No estuvo presente'}</strong></div><div><small>Documento / cargo</small><strong>{closure.responsible.documentNumber ? `${closure.responsible.documentType} ${closure.responsible.documentNumber}` : 'No informado'}{closure.responsible.position ? ` · ${closure.responsible.position}` : ''}</strong></div><div><small>Contacto</small><strong>{closure.responsible.email ?? closure.responsible.phone ?? 'No informado'}</strong></div></div>}
|
|
||||||
|
|
||||||
{closure.closure?.isCurrent && <div className="closure-hash-card"><div><span className="eyebrow">CONTENIDO BLOQUEADO</span><strong>{closure.closure.schemaVersion}</strong><small>Bloqueado {formatDate(closure.act.lockedAt ?? closure.closure.preparedAt)}</small></div><code>{closure.act.lockedSha256 ?? closure.closure.preparedSha256}</code></div>}
|
|
||||||
|
|
||||||
{closure.signatures.length > 0 && <div className="signature-records">{closure.signatures.map((signature) => <article key={signature.id}><div><span className={`status-badge ${signature.status === 'SIGNED' ? 'active' : 'observed'}`}>{signatureStatusLabel(signature.status)}</span><strong>{signature.signerName}</strong><small>{signerTypeLabel(signature.signerType)} · {formatDate(signature.createdAt)}</small></div><code title={signature.signaturePayloadSha256}>{signature.signaturePayloadSha256}</code>{signature.status === 'SIGNED' ? <><button type="button" className="button secondary" onClick={() => viewSignature(signature)}>Ver firma</button>{signature.signerType === 'COMPANY_RESPONSIBLE' && <p><strong>{signature.companyManifestation === 'DISSENT' ? 'Firma en disidencia' : 'Firma en conformidad'}</strong>{signature.companyStatement ? ` · ${signature.companyStatement}` : ''}</p>}</> : <p>{signature.reason}</p>}</article>)}</div>}
|
|
||||||
|
|
||||||
{isSealed && closure.closure?.finalSha256 && <div className="closed-seal"><Icon name="check" /><div><span className="eyebrow">ACTA SELLADA</span><strong>{formatDate(closure.act.sealedAt ?? closure.closure.serverClosedAt)}</strong><p>El Acta quedó sellada e inmutable. La Inspección y sus demás Actas continúan con ciclo independiente.</p><code>{closure.closure.finalSha256}</code></div></div>}
|
|
||||||
</section>;
|
</section>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { InspectionActVersionEvent } from '../../lib/api';
|
import type { InspectionActVersionEvent } from '../../lib/api';
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
DRAFT: 'Borrador',
|
DRAFT: 'En elaboración',
|
||||||
LOCKED: 'Bloqueada · esperando manifestación',
|
LOCKED: 'Para firmar',
|
||||||
SEALED: 'Sellada',
|
SEALED: 'Firmada y cerrada',
|
||||||
READY: 'Lista para cerrar · legado',
|
READY: 'Lista para cerrar · legado',
|
||||||
CLOSED: 'Cerrada · legado',
|
CLOSED: 'Firmada y cerrada',
|
||||||
CANCELLED: 'Cancelada',
|
CANCELLED: 'Cancelada',
|
||||||
RECTIFIED: 'Rectificada · legado',
|
RECTIFIED: 'Rectificada · legado',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,22 +41,22 @@ function localDateTime(value: string | number | Date): string {
|
|||||||
|
|
||||||
function geometryVertices(geometry: GeoJsonGeometry | null): Position[] {
|
function geometryVertices(geometry: GeoJsonGeometry | null): Position[] {
|
||||||
if (!geometry) return [];
|
if (!geometry) return [];
|
||||||
if (geometry.type === 'POINT') return [geometry.coordinates];
|
if (geometry.type === 'Point') return [geometry.coordinates];
|
||||||
if (geometry.type === 'LINESTRING') return geometry.coordinates;
|
if (geometry.type === 'LineString') return geometry.coordinates;
|
||||||
return geometry.coordinates[0]?.slice(0, -1) ?? [];
|
return geometry.coordinates[0]?.slice(0, -1) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function draftGeometry(type: AssetGeometryType, vertices: Position[]): GeoJsonGeometry | null {
|
function draftGeometry(type: AssetGeometryType, vertices: Position[]): GeoJsonGeometry | null {
|
||||||
if (type === 'POINT') return vertices[0] ? { type, coordinates: vertices[0] } : null;
|
if (type === 'POINT') return vertices[0] ? { type: 'Point', coordinates: vertices[0] } : null;
|
||||||
if (type === 'LINESTRING') return vertices.length >= 2 ? { type, coordinates: vertices } : null;
|
if (type === 'LINESTRING') return vertices.length >= 2 ? { type: 'LineString', coordinates: vertices } : null;
|
||||||
if (vertices.length < 3) return 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[]) {
|
function drawingCollection(geometry: GeoJsonGeometry | null, vertices: Position[]) {
|
||||||
const features: unknown[] = [];
|
const features: unknown[] = [];
|
||||||
if (geometry) features.push({ type: 'Feature', properties: { kind: 'shape' }, geometry });
|
if (geometry) features.push({ type: 'Feature', properties: { kind: 'shape' }, geometry });
|
||||||
if (geometry?.type !== 'POINT') {
|
if (geometry?.type !== 'Point') {
|
||||||
vertices.forEach((coordinates, index) => features.push({
|
vertices.forEach((coordinates, index) => features.push({
|
||||||
type: 'Feature', properties: { kind: 'vertex', index: index + 1 },
|
type: 'Feature', properties: { kind: 'vertex', index: index + 1 },
|
||||||
geometry: { type: 'Point', coordinates },
|
geometry: { type: 'Point', coordinates },
|
||||||
@@ -187,7 +187,7 @@ export function AssetGeometryEditor({
|
|||||||
const applyStored = (value: AssetGeometry | null) => {
|
const applyStored = (value: AssetGeometry | null) => {
|
||||||
setStored(value);
|
setStored(value);
|
||||||
if (value) {
|
if (value) {
|
||||||
setType(value.geometry.type);
|
setType(value.geometryType);
|
||||||
setVertices(geometryVertices(value.geometry));
|
setVertices(geometryVertices(value.geometry));
|
||||||
setAccuracyM(value.accuracyM == null ? '' : String(value.accuracyM));
|
setAccuracyM(value.accuracyM == null ? '' : String(value.accuracyM));
|
||||||
setCapturedAt(value.capturedAt ? localDateTime(value.capturedAt) : '');
|
setCapturedAt(value.capturedAt ? localDateTime(value.capturedAt) : '');
|
||||||
|
|||||||
@@ -1,134 +1,80 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import {
|
import { Map as MlMap, Marker, NavigationControl } from 'maplibre-gl';
|
||||||
Map,
|
import type { MapAssetFeature, MapAssetFeatureCollection, MapEntityKind } from '../../lib/api';
|
||||||
NavigationControl,
|
|
||||||
type GeoJSONSource,
|
|
||||||
type MapGeoJSONFeature,
|
|
||||||
} from 'maplibre-gl';
|
|
||||||
import type { MapAssetFeatureCollection } from '../../lib/api';
|
|
||||||
|
|
||||||
const osmStyle = {
|
const osmStyle = {
|
||||||
version: 8 as const,
|
version: 8 as const,
|
||||||
sources: {
|
sources: { osm: { type: 'raster' as const, tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, attribution: '© OpenStreetMap contributors' } },
|
||||||
osm: {
|
|
||||||
type: 'raster' as const,
|
|
||||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
|
||||||
tileSize: 256,
|
|
||||||
attribution: '© OpenStreetMap contributors',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }],
|
layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
const interactiveLayers = ['assets-points', 'assets-lines', 'assets-polygons'];
|
type GpsPoint = { feature: MapAssetFeature; coordinates: [number, number] };
|
||||||
|
|
||||||
function boundsFromFeatures(collection: MapAssetFeatureCollection) {
|
function pointCoordinates(feature: MapAssetFeature): [number, number] | null {
|
||||||
const positions: Array<[number, number]> = [];
|
const geometry = feature.geometry as { type: string; coordinates: unknown };
|
||||||
collection.features.forEach((feature) => {
|
if (geometry.type.toLowerCase() !== 'point' || !Array.isArray(geometry.coordinates)) return null;
|
||||||
if (feature.geometry.type === 'POINT') positions.push(feature.geometry.coordinates);
|
const [longitude, latitude] = geometry.coordinates;
|
||||||
if (feature.geometry.type === 'LINESTRING') positions.push(...feature.geometry.coordinates);
|
if (typeof longitude !== 'number' || typeof latitude !== 'number') return null;
|
||||||
if (feature.geometry.type === 'POLYGON') feature.geometry.coordinates.forEach((ring) => positions.push(...ring));
|
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return null;
|
||||||
});
|
if (longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) return null;
|
||||||
if (!positions.length) return null;
|
return [longitude, latitude];
|
||||||
return positions.reduce<[number, number, number, number]>((result, point) => [
|
|
||||||
Math.min(result[0], point[0]),
|
|
||||||
Math.min(result[1], point[1]),
|
|
||||||
Math.max(result[2], point[0]),
|
|
||||||
Math.max(result[3], point[1]),
|
|
||||||
], [positions[0]![0], positions[0]![1], positions[0]![0], positions[0]![1]]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DhMap({
|
function gpsPoints(collection: MapAssetFeatureCollection): GpsPoint[] {
|
||||||
data,
|
return collection.features.flatMap((feature) => {
|
||||||
selectedId,
|
const coordinates = pointCoordinates(feature);
|
||||||
onSelect,
|
return coordinates ? [{ feature, coordinates }] : [];
|
||||||
}: {
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitToPoints(map: MlMap, points: GpsPoint[]) {
|
||||||
|
if (!points.length) return;
|
||||||
|
const bounds = points.reduce<[number, number, number, number]>((result, point) => [
|
||||||
|
Math.min(result[0], point.coordinates[0]), Math.min(result[1], point.coordinates[1]),
|
||||||
|
Math.max(result[2], point.coordinates[0]), Math.max(result[3], point.coordinates[1]),
|
||||||
|
], [points[0]!.coordinates[0], points[0]!.coordinates[1], points[0]!.coordinates[0], points[0]!.coordinates[1]]);
|
||||||
|
if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) map.flyTo({ center: [bounds[0], bounds[1]], zoom: 15 });
|
||||||
|
else map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 80, maxZoom: 15 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function visualOffsets(points: GpsPoint[]) {
|
||||||
|
const groups = new Map<string, GpsPoint[]>();
|
||||||
|
points.forEach((point) => {
|
||||||
|
const key = `${point.coordinates[0].toFixed(5)}:${point.coordinates[1].toFixed(5)}`;
|
||||||
|
groups.set(key, [...(groups.get(key) ?? []), point]);
|
||||||
|
});
|
||||||
|
const offsets = new Map<string, [number, number]>();
|
||||||
|
groups.forEach((group) => {
|
||||||
|
group.forEach((point, index) => {
|
||||||
|
if (group.length === 1) { offsets.set(String(point.feature.id), [0, 0]); return; }
|
||||||
|
const angle = (Math.PI * 2 * index) / group.length;
|
||||||
|
const radius = Math.min(18, 8 + group.length);
|
||||||
|
offsets.set(String(point.feature.id), [Math.cos(angle) * radius, Math.sin(angle) * radius]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return offsets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DhMap({ data, selectedId, onSelect }: {
|
||||||
data: MapAssetFeatureCollection;
|
data: MapAssetFeatureCollection;
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
onSelect: (id: string | null) => void;
|
onSelect: (id: string | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const mapRef = useRef<Map | null>(null);
|
const mapRef = useRef<MlMap | null>(null);
|
||||||
|
const markersRef = useRef<Marker[]>([]);
|
||||||
const onSelectRef = useRef(onSelect);
|
const onSelectRef = useRef(onSelect);
|
||||||
const dataRef = useRef(data);
|
|
||||||
onSelectRef.current = onSelect;
|
onSelectRef.current = onSelect;
|
||||||
dataRef.current = data;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
const map = new Map({
|
const map = new MlMap({ container: containerRef.current, style: osmStyle, center: [-68.8458, -32.8895], zoom: 6 });
|
||||||
container: containerRef.current,
|
|
||||||
style: osmStyle,
|
|
||||||
center: [-68.8458, -32.8895],
|
|
||||||
zoom: 6,
|
|
||||||
});
|
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
map.addControl(new NavigationControl(), 'top-right');
|
map.addControl(new NavigationControl(), 'top-right');
|
||||||
|
map.on('click', () => onSelectRef.current(null));
|
||||||
map.on('load', () => {
|
|
||||||
map.addSource('assets', { type: 'geojson', data: dataRef.current as never });
|
|
||||||
map.addLayer({
|
|
||||||
id: 'assets-polygons', type: 'fill', source: 'assets',
|
|
||||||
filter: ['==', ['geometry-type'], 'Polygon'],
|
|
||||||
paint: { 'fill-color': '#2864dc', 'fill-opacity': 0.22, 'fill-outline-color': '#184caf' },
|
|
||||||
});
|
|
||||||
map.addLayer({
|
|
||||||
id: 'assets-lines', type: 'line', source: 'assets',
|
|
||||||
filter: ['==', ['geometry-type'], 'LineString'],
|
|
||||||
paint: { 'line-color': '#2864dc', 'line-width': 3 },
|
|
||||||
});
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
map.addLayer({
|
|
||||||
id: 'assets-selected-polygons', type: 'line', source: 'assets',
|
|
||||||
filter: ['all', ['==', ['geometry-type'], 'Polygon'], ['==', ['get', 'id'], '']],
|
|
||||||
paint: { 'line-color': '#f59e0b', 'line-width': 4 },
|
|
||||||
});
|
|
||||||
map.addLayer({
|
|
||||||
id: 'assets-selected-lines', type: 'line', source: 'assets',
|
|
||||||
filter: ['all', ['==', ['geometry-type'], 'LineString'], ['==', ['get', 'id'], '']],
|
|
||||||
paint: { 'line-color': '#f59e0b', 'line-width': 6 },
|
|
||||||
});
|
|
||||||
map.addLayer({
|
|
||||||
id: 'assets-selected-points', type: 'circle', source: 'assets',
|
|
||||||
filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'id'], '']],
|
|
||||||
paint: {
|
|
||||||
'circle-radius': 10, 'circle-color': '#f59e0b',
|
|
||||||
'circle-stroke-color': '#ffffff', 'circle-stroke-width': 3,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const click = (event: { features?: MapGeoJSONFeature[] }) => {
|
|
||||||
const id = event.features?.[0]?.properties?.id;
|
|
||||||
onSelectRef.current(typeof id === 'string' ? id : null);
|
|
||||||
};
|
|
||||||
interactiveLayers.forEach((layer) => {
|
|
||||||
map.on('click', layer, click);
|
|
||||||
map.on('mouseenter', layer, () => { map.getCanvas().style.cursor = 'pointer'; });
|
|
||||||
map.on('mouseleave', layer, () => { map.getCanvas().style.cursor = ''; });
|
|
||||||
});
|
|
||||||
map.on('click', (event) => {
|
|
||||||
const hits = map.queryRenderedFeatures(event.point, { layers: interactiveLayers });
|
|
||||||
if (!hits.length) onSelectRef.current(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
const bounds = boundsFromFeatures(dataRef.current);
|
|
||||||
if (bounds) {
|
|
||||||
if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) {
|
|
||||||
map.flyTo({ center: [bounds[0], bounds[1]], zoom: 13 });
|
|
||||||
} else {
|
|
||||||
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 55, maxZoom: 15 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
markersRef.current.forEach((marker) => marker.remove());
|
||||||
|
markersRef.current = [];
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
map.remove();
|
map.remove();
|
||||||
};
|
};
|
||||||
@@ -136,31 +82,24 @@ export function DhMap({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map?.isStyleLoaded()) return;
|
if (!map) return;
|
||||||
(map.getSource('assets') as GeoJSONSource | undefined)?.setData(data as never);
|
markersRef.current.forEach((marker) => marker.remove());
|
||||||
const bounds = boundsFromFeatures(data);
|
markersRef.current = [];
|
||||||
if (bounds) {
|
const points = gpsPoints(data);
|
||||||
if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) {
|
const offsets = visualOffsets(points);
|
||||||
map.flyTo({ center: [bounds[0], bounds[1]], zoom: 13 });
|
points.forEach(({ feature, coordinates }) => {
|
||||||
} else {
|
const kind = (feature.properties.entityKind ?? 'ASSET') as MapEntityKind;
|
||||||
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 55, maxZoom: 15 });
|
const element = document.createElement('button');
|
||||||
}
|
element.type = 'button';
|
||||||
}
|
element.className = `map-gps-marker kind-${kind.toLowerCase()}${String(feature.id) === selectedId ? ' selected' : ''}`;
|
||||||
}, [data]);
|
element.title = `${feature.properties.typeName}: ${feature.properties.name}`;
|
||||||
|
element.setAttribute('aria-label', element.title);
|
||||||
useEffect(() => {
|
element.addEventListener('click', (event) => { event.stopPropagation(); onSelectRef.current(String(feature.id)); });
|
||||||
const map = mapRef.current;
|
const marker = new Marker({ element, offset: offsets.get(String(feature.id)) ?? [0, 0] }).setLngLat(coordinates).addTo(map);
|
||||||
if (!map?.isStyleLoaded()) return;
|
markersRef.current.push(marker);
|
||||||
const id = selectedId ?? '__none__';
|
|
||||||
const filters: Array<[string, string]> = [
|
|
||||||
['assets-selected-points', 'Point'],
|
|
||||||
['assets-selected-lines', 'LineString'],
|
|
||||||
['assets-selected-polygons', 'Polygon'],
|
|
||||||
];
|
|
||||||
filters.forEach(([layer, geometryType]) => {
|
|
||||||
map.setFilter(layer, ['all', ['==', ['geometry-type'], geometryType], ['==', ['get', 'id'], id]]);
|
|
||||||
});
|
});
|
||||||
}, [selectedId]);
|
fitToPoints(map, points);
|
||||||
|
}, [data, selectedId]);
|
||||||
|
|
||||||
return <div ref={containerRef} className="map-canvas" />;
|
return <div ref={containerRef} className="map-canvas" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,13 +18,12 @@ const operational: NavItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const followUp: NavItem[] = [
|
const followUp: NavItem[] = [
|
||||||
{ to: '/seguimiento-actas', label: 'Seguimiento de actas', icon: 'clipboard', permission: 'inspection_acts.read' },
|
|
||||||
{ to: '/hallazgos', label: 'Hallazgos', icon: 'alert', permission: 'inspection_findings.read' },
|
{ to: '/hallazgos', label: 'Hallazgos', icon: 'alert', permission: 'inspection_findings.read' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const documents: NavItem[] = [
|
const documents: NavItem[] = [
|
||||||
{ to: '/actas', label: 'Actas', icon: 'clipboard', permission: 'inspection_acts.read' },
|
{ to: '/actas', label: 'Actas', icon: 'clipboard', permission: 'inspection_acts.read' },
|
||||||
{ to: '/informes', label: 'Informes', icon: 'audit', permission: 'inspection_reports.read' },
|
{ to: '/informes', label: 'Informes y respuestas', icon: 'audit', permission: 'inspection_reports.read' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const master: NavItem[] = [
|
const master: NavItem[] = [
|
||||||
|
|||||||
+56
-12
@@ -536,9 +536,9 @@ export type Position = [number, number];
|
|||||||
export type AssetGeometryType = 'POINT' | 'LINESTRING' | 'POLYGON';
|
export type AssetGeometryType = 'POINT' | 'LINESTRING' | 'POLYGON';
|
||||||
|
|
||||||
export type GeoJsonGeometry =
|
export type GeoJsonGeometry =
|
||||||
| { type: 'POINT'; coordinates: Position }
|
| { type: 'Point'; coordinates: Position }
|
||||||
| { type: 'LINESTRING'; coordinates: Position[] }
|
| { type: 'LineString'; coordinates: Position[] }
|
||||||
| { type: 'POLYGON'; coordinates: Position[][] };
|
| { type: 'Polygon'; coordinates: Position[][] };
|
||||||
|
|
||||||
export interface AssetGeometry {
|
export interface AssetGeometry {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
@@ -553,21 +553,34 @@ export interface AssetGeometry {
|
|||||||
updatedBy: string | null;
|
updatedBy: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MapEntityKind = 'ASSET' | 'YACIMIENTO' | 'COMPANY' | 'ACT' | 'FINDING';
|
||||||
|
|
||||||
export interface MapAssetProperties {
|
export interface MapAssetProperties {
|
||||||
id: string;
|
id: string;
|
||||||
|
entityId?: string;
|
||||||
|
entityKind?: MapEntityKind;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
commonName?: string | null;
|
commonName?: string | null;
|
||||||
typeId: string;
|
typeId?: string | null;
|
||||||
typeCode: string;
|
typeCode?: string | null;
|
||||||
typeName: string;
|
typeName: string;
|
||||||
parentId: string | null;
|
parentId?: string | null;
|
||||||
parentName: string | null;
|
parentName?: string | null;
|
||||||
informationStatus: AssetInformationStatus;
|
informationStatus?: AssetInformationStatus | null;
|
||||||
geometryType: AssetGeometryType;
|
geometryType: AssetGeometryType;
|
||||||
accuracyM: number | null;
|
accuracyM?: number | null;
|
||||||
capturedAt: string | null;
|
capturedAt?: string | null;
|
||||||
updatedAt: string;
|
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 {
|
export interface MapAssetFeature {
|
||||||
@@ -1473,7 +1486,17 @@ export interface InspectionFinding {
|
|||||||
currentVersion: number;
|
currentVersion: number;
|
||||||
closedAt: string | null;
|
closedAt: string | null;
|
||||||
closureNotes: 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: {
|
catalog: {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -2162,6 +2185,15 @@ export async function getAssetGeometry(assetId: string) {
|
|||||||
return (await apiRequest<{ data: AssetGeometry | null }>(`/assets/${assetId}/geometry`)).data;
|
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: {
|
export function upsertAssetGeometry(assetId: string, input: {
|
||||||
geometry: GeoJsonGeometry;
|
geometry: GeoJsonGeometry;
|
||||||
accuracyM?: number | null;
|
accuracyM?: number | null;
|
||||||
@@ -2169,7 +2201,7 @@ export function upsertAssetGeometry(assetId: string, input: {
|
|||||||
deviceLabel?: string | null;
|
deviceLabel?: string | null;
|
||||||
}) {
|
}) {
|
||||||
return apiRequest<AssetGeometry>(`/assets/${assetId}/geometry`, {
|
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: {
|
export function getMapAssets(params: {
|
||||||
bbox?: string;
|
bbox?: string;
|
||||||
typeId?: string;
|
typeId?: string;
|
||||||
@@ -2707,6 +2747,10 @@ export function getInspectionAct(id: string) {
|
|||||||
return apiRequest<InspectionAct>(`/inspection-acts/${id}`);
|
return apiRequest<InspectionAct>(`/inspection-acts/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getInspectionActConsolidatedPdfBlob(actId: string) {
|
||||||
|
return apiBlobRequest(`/inspection-acts/${actId}/consolidated-pdf`);
|
||||||
|
}
|
||||||
|
|
||||||
export function getInspectionActPdfBlob(actId: string) {
|
export function getInspectionActPdfBlob(actId: string) {
|
||||||
return apiBlobRequest(`/inspection-acts/${actId}/pdf`);
|
return apiBlobRequest(`/inspection-acts/${actId}/pdf`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,11 +73,23 @@ export interface InspectionActListItemF4 {
|
|||||||
findingCount: number;
|
findingCount: number;
|
||||||
companies: Array<{ id: string; code: string; name: string }>;
|
companies: Array<{ id: string; code: string; name: string }>;
|
||||||
areas: 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 | {
|
report: null | {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
status: InspectionReportStatusF4;
|
status: InspectionReportStatusF4;
|
||||||
pdfStatus: InspectionReportPdfStatus;
|
pdfStatus: InspectionReportPdfStatus;
|
||||||
|
wordStatus: 'PENDING' | 'READY' | 'FAILED';
|
||||||
|
gedoIfIdentifier: string | null;
|
||||||
|
gedoOfficializedAt: string | null;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
};
|
};
|
||||||
createdBy: InspectionPerson | null;
|
createdBy: InspectionPerson | null;
|
||||||
|
|||||||
@@ -64,6 +64,14 @@ export interface InspectionReportDetailF4 {
|
|||||||
companies: Array<{ id: string; code: string; name: string }>;
|
companies: Array<{ id: string; code: string; name: string }>;
|
||||||
areas: Array<{ id: string; code: string; name: string }>;
|
areas: Array<{ id: string; code: string; name: string }>;
|
||||||
findingCount: number;
|
findingCount: number;
|
||||||
|
responseDueOn: string | null;
|
||||||
|
deadlineReason: string | null;
|
||||||
|
deadlines: Array<{ id: string; reportId: string | null; responseDueOn: string; reason: string; createdAt: string }>;
|
||||||
|
findings: Array<{ id: string; code: string; title: string; status: string; assetCode: string; assetName: string; responseDueOn: string | null }>;
|
||||||
|
companyResponses: Array<{
|
||||||
|
id: string; reportId: string | null; receivedOn: string; details: string | null; committedCorrectionOn: string | null;
|
||||||
|
contactName: string | null; contactEmail: string | null; originalName: string | null; sizeBytes: number | null; sha256: string | null; createdAt: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingInspectionReportF4 {
|
export interface PendingInspectionReportF4 {
|
||||||
@@ -176,6 +184,30 @@ export function officializeInspectionReport(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setInspectionReportResponseDeadline(
|
||||||
|
id: string,
|
||||||
|
input: { responseDueOn: string; reason?: string | null },
|
||||||
|
) {
|
||||||
|
return apiRequest<InspectionReportWorkflowView>(`/inspection-reports/${id}/response-deadline`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addInspectionReportCompanyResponse(
|
||||||
|
id: string,
|
||||||
|
input: { receivedOn: string; details?: string; committedCorrectionOn?: string; contactName?: string; contactEmail?: string; file?: File | null },
|
||||||
|
) {
|
||||||
|
const body = new FormData();
|
||||||
|
body.set('receivedOn', input.receivedOn);
|
||||||
|
if (input.details?.trim()) body.set('details', input.details.trim());
|
||||||
|
if (input.committedCorrectionOn) body.set('committedCorrectionOn', input.committedCorrectionOn);
|
||||||
|
if (input.contactName?.trim()) body.set('contactName', input.contactName.trim());
|
||||||
|
if (input.contactEmail?.trim()) body.set('contactEmail', input.contactEmail.trim());
|
||||||
|
if (input.file) body.set('file', input.file);
|
||||||
|
return apiRequest<InspectionReportWorkflowView>(`/inspection-reports/${id}/company-responses`, { method: 'POST', body });
|
||||||
|
}
|
||||||
|
|
||||||
export function addInspectionReportFollowUp(
|
export function addInspectionReportFollowUp(
|
||||||
id: string,
|
id: string,
|
||||||
input: {
|
input: {
|
||||||
@@ -205,3 +237,15 @@ export function inspectionReportWordDownloadUrl(id: string) {
|
|||||||
export function inspectionReportGedoPdfDownloadUrl(id: string) {
|
export function inspectionReportGedoPdfDownloadUrl(id: string) {
|
||||||
return `/api/v3/inspection-reports/${id}/gedo-pdf`;
|
return `/api/v3/inspection-reports/${id}/gedo-pdf`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function inspectionReportCompanyResponseDownloadUrl(reportId: string, responseId: string) {
|
||||||
|
return `/api/v3/inspection-reports/${reportId}/company-responses/${responseId}/content`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inspectionReportFollowUpDownloadUrl(reportId: string, followUpId: string) {
|
||||||
|
return `/api/v3/inspection-reports/${reportId}/follow-ups/${followUpId}/content`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inspectionReportConsolidatedWordDownloadUrl(reportId: string) {
|
||||||
|
return `/api/v3/inspection-reports/${reportId}/consolidated-word`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { FormEvent } from 'react';
|
|
||||||
import { Link, useParams } from 'react-router';
|
import { Link, useParams } from 'react-router';
|
||||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||||
import { addActCompanyResponse, actCompanyResponseContentUrl, getActAdministration, setActResponseDeadline } from '../lib/api';
|
import { actCompanyResponseContentUrl, getActAdministration } from '../lib/api';
|
||||||
import type { ActAdministrationDetail } from '../lib/api';
|
import type { ActAdministrationDetail } from '../lib/api';
|
||||||
|
import { getInspectionActF4 } from '../lib/inspectionActF4Api';
|
||||||
import { formatDate } from '../lib/format';
|
import { formatDate } from '../lib/format';
|
||||||
|
|
||||||
export function ActAdministrationDetailPage(){
|
export function ActAdministrationDetailPage() {
|
||||||
const {actId=''}=useParams(); const [data,setData]=useState<ActAdministrationDetail|null>(null); const [error,setError]=useState(''); const [busy,setBusy]=useState(false); const [due,setDue]=useState(''); const [reason,setReason]=useState('Plazo administrativo otorgado a la empresa'); const [received,setReceived]=useState(new Date().toISOString().slice(0,10)); const [details,setDetails]=useState(''); const [commitment,setCommitment]=useState(''); const [file,setFile]=useState<File|undefined>();
|
const { actId = '' } = useParams();
|
||||||
const load=()=>getActAdministration(actId).then(setData).catch(e=>setError(errorMessage(e))); useEffect(()=>{void load()},[actId]);
|
const [data, setData] = useState<ActAdministrationDetail | null>(null);
|
||||||
const deadline=async(e:FormEvent)=>{e.preventDefault();setBusy(true);setError('');try{await setActResponseDeadline(actId,{responseDueOn:due,reason});setDue('');await load()}catch(err){setError(errorMessage(err))}finally{setBusy(false)}};
|
const [reportId, setReportId] = useState<string | null>(null);
|
||||||
const response=async(e:FormEvent)=>{e.preventDefault();setBusy(true);setError('');try{await addActCompanyResponse(actId,{receivedOn:received,details:details||undefined,committedCorrectionOn:commitment||undefined,file});setDetails('');setCommitment('');setFile(undefined);await load()}catch(err){setError(errorMessage(err))}finally{setBusy(false)}};
|
const [error, setError] = useState('');
|
||||||
if(!data&&!error)return <LoadingBlock label="Cargando expediente administrativo…"/>;
|
useEffect(() => {
|
||||||
return <section>{error&&<Alert>{error}</Alert>}{data&&<><div className="page-heading"><div><span className="eyebrow">EXPEDIENTE ADMINISTRATIVO</span><h1>{data.act.actCode}</h1><p>{data.act.areaName??'Área sin asignar'} · {data.act.companyName??'Empresa sin asignar'} · {data.act.openFindingCount} hallazgos abiertos</p></div><Link className="button secondary" to={`/inspecciones/actas/${data.act.actId}`}>Ver Acta</Link></div>
|
Promise.all([getActAdministration(actId), getInspectionActF4(actId)])
|
||||||
<div className="detail-grid"><div className="card"><h2>Plazo de respuesta</h2><p>El plazo aplica al conjunto completo de hallazgos del Acta.</p><form className="form-grid" onSubmit={deadline}><label><span>Vencimiento *</span><input type="date" required value={due} onChange={e=>setDue(e.target.value)}/></label><label className="full"><span>Motivo *</span><textarea required minLength={3} value={reason} onChange={e=>setReason(e.target.value)}/></label><button className="button primary" disabled={busy}>Registrar nuevo plazo</button></form>{data.deadlines.length>0&&<div className="stack-list">{data.deadlines.map(d=><div key={d.id}><strong>{formatDate(d.responseDueOn)}</strong><small>{d.reason}</small></div>)}</div>}</div>
|
.then(([detail, act]) => { setData(detail); setReportId(act.report?.id ?? null); })
|
||||||
<div className="card"><h2>Respuesta de la empresa</h2><form className="form-grid" onSubmit={response}><label><span>Recibida el *</span><input type="date" required value={received} onChange={e=>setReceived(e.target.value)}/></label><label><span>Fecha comprometida</span><input type="date" value={commitment} onChange={e=>setCommitment(e.target.value)}/></label><label className="full"><span>Detalle</span><textarea value={details} onChange={e=>setDetails(e.target.value)} placeholder="Resumen de la presentación de la empresa"/></label><label className="full"><span>PDF presentado</span><input type="file" accept="application/pdf,.pdf" onChange={e=>setFile(e.target.files?.[0])}/></label><button className="button primary" disabled={busy}>Registrar respuesta</button></form>{data.responses.length>0&&<div className="stack-list">{data.responses.map(r=><div key={r.id}><strong>{formatDate(r.receivedOn)}</strong><small>{r.details??'Sin detalle'}{r.committedCorrectionOn?` · compromiso ${formatDate(r.committedCorrectionOn)}`:''}</small>{r.originalName&&<a className="text-link" href={actCompanyResponseContentUrl(r.id)} target="_blank" rel="noreferrer">Abrir PDF</a>}</div>)}</div>}</div></div>
|
.catch((requestError) => setError(errorMessage(requestError)));
|
||||||
<div className="card"><h2>Hallazgos del Acta</h2><div className="table-scroll"><table><thead><tr><th>Código</th><th>Hallazgo</th><th>Estado</th><th>Próximo control</th></tr></thead><tbody>{data.findings.map(f=><tr key={f.id}><td><Link className="text-link" to={`/hallazgos/${f.id}`}>{f.code}</Link></td><td>{f.title}</td><td>{f.status}</td><td>{f.nextControlOn?formatDate(f.nextControlOn):'Sin programar'}</td></tr>)}</tbody></table></div></div></>}</section>;
|
}, [actId]);
|
||||||
|
if (!data && !error) return <LoadingBlock label="Cargando histórico…" />;
|
||||||
|
return <section>
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
{data && <>
|
||||||
|
<div className="page-heading"><div><span className="eyebrow">HISTÓRICO ADMINISTRATIVO</span><h1>{data.act.actCode}</h1><p>Registro previo de plazos y respuestas asociados al Acta.</p></div><Link className="button secondary" to={`/inspecciones/actas/${actId}`}>Ver Acta</Link></div>
|
||||||
|
{reportId && <p>Las nuevas respuestas y verificaciones se registran en el <Link to={`/informes/${reportId}`}>Informe relacionado</Link>.</p>}
|
||||||
|
<section className="panel"><h2>Antecedentes conservados</h2>
|
||||||
|
{!data.deadlines.length && !data.responses.length && <p>No hay antecedentes administrativos anteriores.</p>}
|
||||||
|
{[...data.deadlines.map((item) => ({ id: item.id, date: item.createdAt, title: 'Plazo registrado', description: `${formatDate(item.responseDueOn)} · ${item.reason}`, fileId: null as string | null, fileName: null as string | null })), ...data.responses.map((item) => ({ id: item.id, date: item.receivedOn, title: 'Respuesta de la empresa', description: item.details ?? 'Sin descripción', fileId: item.id, fileName: item.originalName }))].sort((a, b) => b.date.localeCompare(a.date)).map((item) => <div key={item.id} className="act-signer-row"><div><strong>{item.title}</strong><small>{formatDate(item.date)}</small><p>{item.description}</p></div>{item.fileName && item.fileId && <a className="button secondary" href={actCompanyResponseContentUrl(item.fileId)} target="_blank" rel="noreferrer">Abrir {item.fileName}</a>}</div>)}
|
||||||
|
</section>
|
||||||
|
</>}
|
||||||
|
</section>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SortableTable } from '../components/SortableTable';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
@@ -18,5 +19,5 @@ export function ActAdministrationPage(){
|
|||||||
useEffect(()=>{setLoading(true);setError('');listActAdministrationQueue({state,areaId:ctx.areaId||undefined,companyId:ctx.companyId||undefined,page:1,pageSize:50}).then(r=>{setItems(r.data);setMeta(r.meta);setCounters(r.counters)}).catch(e=>setError(errorMessage(e))).finally(()=>setLoading(false));},[state,ctx.areaId,ctx.companyId]);
|
useEffect(()=>{setLoading(true);setError('');listActAdministrationQueue({state,areaId:ctx.areaId||undefined,companyId:ctx.companyId||undefined,page:1,pageSize:50}).then(r=>{setItems(r.data);setMeta(r.meta);setCounters(r.counters)}).catch(e=>setError(errorMessage(e))).finally(()=>setLoading(false));},[state,ctx.areaId,ctx.companyId]);
|
||||||
return <section><div className="page-heading"><div><span className="eyebrow">SEGUIMIENTO ADMINISTRATIVO</span><h1>Seguimiento de Actas</h1><p>El plazo y la respuesta de la empresa se gestionan sobre el Acta completa. GEDO no activa el vencimiento por sí solo; el plazo se define cuando corresponda. Los hallazgos quedan dentro de su expediente.</p></div></div>
|
return <section><div className="page-heading"><div><span className="eyebrow">SEGUIMIENTO ADMINISTRATIVO</span><h1>Seguimiento de Actas</h1><p>El plazo y la respuesta de la empresa se gestionan sobre el Acta completa. GEDO no activa el vencimiento por sí solo; el plazo se define cuando corresponda. Los hallazgos quedan dentro de su expediente.</p></div></div>
|
||||||
<div className="status-tabs">{states.map(s=><button key={s.value} className={state===s.value?'active':''} onClick={()=>setState(s.value)}>{s.label}{s.value!=='ALL'&&<small>{counters[s.value]??0}</small>}</button>)}</div>
|
<div className="status-tabs">{states.map(s=><button key={s.value} className={state===s.value?'active':''} onClick={()=>setState(s.value)}>{s.label}{s.value!=='ALL'&&<small>{counters[s.value]??0}</small>}</button>)}</div>
|
||||||
{error&&<Alert>{error}</Alert>}{loading?<LoadingBlock label="Cargando seguimiento…"/>:items.length===0?<EmptyState title="Sin actas" text="No hay actas en este estado para el contexto seleccionado."/>:<div className="table-panel"><div className="table-summary"><strong>{meta.total} acta{meta.total===1?'':'s'}</strong></div><div className="table-scroll"><table><thead><tr><th>Acta</th><th>Área / empresa</th><th>Hallazgos</th><th>Plazo empresa</th><th>Respuesta</th><th>Estado</th><th/></tr></thead><tbody>{items.map(a=><tr key={a.actId}><td><strong>{a.actCode}</strong><small className="block-muted">{formatDate(a.occurredAt)}</small></td><td><strong>{a.areaName??'—'}</strong><small className="block-muted">{a.companyName??'—'}</small></td><td>{a.openFindingCount} abiertos / {a.findingCount}</td><td>{a.responseDueOn?formatDate(a.responseDueOn):'Sin definir'}</td><td>{a.responseReceivedOn?formatDate(a.responseReceivedOn):'Pendiente'}</td><td><span className={`status-badge ${a.adminState==='OVERDUE'||a.adminState==='COMMITMENT_OVERDUE'?'danger':'pending'}`}>{label(a.adminState)}</span></td><td><Link className="button secondary" to={`/seguimiento-actas/${a.actId}`}>Gestionar</Link></td></tr>)}</tbody></table></div></div>}</section>;
|
{error&&<Alert>{error}</Alert>}{loading?<LoadingBlock label="Cargando seguimiento…"/>:items.length===0?<EmptyState title="Sin actas" text="No hay actas en este estado para el contexto seleccionado."/>:<div className="table-panel"><div className="table-summary"><strong>{meta.total} acta{meta.total===1?'':'s'}</strong></div><div className="table-scroll"><SortableTable><thead><tr><th>Acta</th><th>Área / empresa</th><th>Hallazgos</th><th>Plazo empresa</th><th>Respuesta</th><th>Estado</th><th/></tr></thead><tbody>{items.map(a=><tr key={a.actId}><td><strong>{a.actCode}</strong><small className="block-muted">{formatDate(a.occurredAt)}</small></td><td><strong>{a.areaName??'—'}</strong><small className="block-muted">{a.companyName??'—'}</small></td><td>{a.openFindingCount} abiertos / {a.findingCount}</td><td>{a.responseDueOn?formatDate(a.responseDueOn):'Sin definir'}</td><td>{a.responseReceivedOn?formatDate(a.responseReceivedOn):'Pendiente'}</td><td><span className={`status-badge ${a.adminState==='OVERDUE'||a.adminState==='COMMITMENT_OVERDUE'?'danger':'pending'}`}>{label(a.adminState)}</span></td><td><Link className="button secondary" to={`/seguimiento-actas/${a.actId}`}>Gestionar</Link></td></tr>)}</tbody></SortableTable></div></div>}</section>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SortableTable } from '../components/SortableTable';
|
||||||
import { SearchableSelect } from '../components/SearchableSelect';
|
import { SearchableSelect } from '../components/SearchableSelect';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
@@ -18,9 +19,9 @@ import { formatDate } from '../lib/format';
|
|||||||
|
|
||||||
const statuses: Array<{ value: InspectionActStatusF4 | ''; label: string }> = [
|
const statuses: Array<{ value: InspectionActStatusF4 | ''; label: string }> = [
|
||||||
{ value: '', label: 'Todos los estados' },
|
{ value: '', label: 'Todos los estados' },
|
||||||
{ value: 'DRAFT', label: 'Borrador' },
|
{ value: 'DRAFT', label: 'En elaboración' },
|
||||||
{ value: 'LOCKED', label: 'Bloqueada · esperando manifestación' },
|
{ value: 'LOCKED', label: 'Para firmar' },
|
||||||
{ value: 'SEALED', label: 'Sellada' },
|
{ value: 'SEALED', label: 'Firmada y cerrada' },
|
||||||
{ value: 'CANCELLED', label: 'Cancelada' },
|
{ value: 'CANCELLED', label: 'Cancelada' },
|
||||||
{ value: 'READY', label: 'Lista para cerrar · legado' },
|
{ value: 'READY', label: 'Lista para cerrar · legado' },
|
||||||
{ value: 'CLOSED', label: 'Cerrada · legado' },
|
{ value: 'CLOSED', label: 'Cerrada · legado' },
|
||||||
@@ -111,15 +112,15 @@ export function ActsPage() {
|
|||||||
{error && <Alert>{error}</Alert>}
|
{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">
|
{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-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"><SortableTable><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>{act.title} · {formatDate(act.occurredAt)}</small></div></td>
|
<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={`/inspecciones/${act.visitId}`}>{act.visit.code}</Link></td>
|
||||||
<td><Link className="text-link" to={`/hallazgos?search=${encodeURIComponent(act.code)}`}>{act.findingCount}</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><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>
|
<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>
|
</tr>)}</tbody></SortableTable></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>
|
<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>
|
||||||
</div>}
|
</div>}
|
||||||
</section>;
|
</section>;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SortableTable } from '../components/SortableTable';
|
||||||
import { SearchableSelect } from '../components/SearchableSelect';
|
import { SearchableSelect } from '../components/SearchableSelect';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
@@ -654,9 +655,9 @@ Escribí IMPORTAR para continuar:`);
|
|||||||
{PLAN_GROUPS.map((group) => {
|
{PLAN_GROUPS.map((group) => {
|
||||||
const visibleKinds = group.kinds.filter((kind) => Number(record(planByKind[kind])?.total ?? 0) > 0);
|
const visibleKinds = group.kinds.filter((kind) => Number(record(planByKind[kind])?.total ?? 0) > 0);
|
||||||
if (!visibleKinds.length) return null;
|
if (!visibleKinds.length) return null;
|
||||||
return <section key={group.label} className="import-plan-group"><h4>{group.label}</h4><div className="table-scroll"><table><thead><tr><th>Entidad</th><th>Crear nuevos</th><th>Usar existentes</th><th>Revisar</th><th>Ignorar</th><th>Total</th><th></th></tr></thead><tbody>{visibleKinds.map((kind) => { const counts = record(planByKind[kind])!; return <tr key={kind}><td><strong>{ENTITY_LABELS[kind]}</strong></td><td>{Number(counts.create ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.match ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.review ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.ignore ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.total ?? 0).toLocaleString('es-AR')}</td><td><button type="button" className="button secondary small" onClick={() => void loadPlanDetail(kind, 1, null)}>Ver detalle</button></td></tr>; })}</tbody></table></div></section>;
|
return <section key={group.label} className="import-plan-group"><h4>{group.label}</h4><div className="table-scroll"><SortableTable><thead><tr><th>Entidad</th><th>Crear nuevos</th><th>Usar existentes</th><th>Revisar</th><th>Ignorar</th><th>Total</th><th></th></tr></thead><tbody>{visibleKinds.map((kind) => { const counts = record(planByKind[kind])!; return <tr key={kind}><td><strong>{ENTITY_LABELS[kind]}</strong></td><td>{Number(counts.create ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.match ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.review ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.ignore ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.total ?? 0).toLocaleString('es-AR')}</td><td><button type="button" className="button secondary small" onClick={() => void loadPlanDetail(kind, 1, null)}>Ver detalle</button></td></tr>; })}</tbody></SortableTable></div></section>;
|
||||||
})}
|
})}
|
||||||
{(planDetailKind || planDetailAction) && <div className="import-plan-detail"><div className="table-summary"><strong>{planDetailKind ? ENTITY_LABELS[planDetailKind] : planDetailAction ? PLAN_ACTION_LABELS[planDetailAction] : 'Detalle del plan'}</strong><span>{planDetailMeta.total.toLocaleString('es-AR')} elementos únicos</span></div>{planDetailLoading ? <LoadingBlock label="Cargando detalle del plan…" /> : <><div className="table-scroll"><table><thead><tr><th>Decisión</th><th>Entidad / relación</th><th>Qué hará el sistema</th><th>Filas fuente</th></tr></thead><tbody>{planDetailItems.map((item) => { const objectId = matchedObjectId(item); return <tr key={item.id}><td><span className={`status-badge ${item.action === 'REVIEW' ? 'observed' : item.action === 'CREATE' ? 'active' : item.action === 'MATCH' ? 'pending' : 'inactive'}`}>{PLAN_ACTION_LABELS[item.action]}</span></td><td><strong>{item.displayName}</strong><small className="cell-subtle">{ENTITY_LABELS[item.entityKind]}</small>{item.entityKind === 'DEPARTMENT' && <small className="cell-subtle">Código {String(item.payload.departmentCode ?? '—')}</small>}{item.entityKind === 'LEGAL_RIGHT' && <small className="cell-subtle">{String(item.payload.rightType ?? '—')} · estado inicial PENDING</small>}</td><td>{item.action === 'MATCH' ? <div className="import-match-explanation"><strong>Se reutiliza un registro del inventario</strong><span>{MATCH_CRITERIA[item.entityKind]}</span>{objectId && <code>ID {objectId.slice(0, 8)}…</code>}</div> : item.action === 'CREATE' ? <div className="import-match-explanation"><strong>Se creará un registro nuevo</strong><span>No se encontró una coincidencia exacta y segura con las reglas de este tipo.</span></div> : item.action === 'REVIEW' ? <div className="row-issue-list">{item.reviewCodes.slice(0, 3).map((code) => <span key={code}>{reviewGuidance(code).title}</span>)}</div> : <span className="cell-subtle">No se incorporará al inventario.</span>}</td><td>{sourceRowsLabel(item.sourceRowNumbers)}</td></tr>; })}</tbody></table></div>{planDetailMeta.totalPages > 1 && <div className="pagination"><button type="button" disabled={planDetailMeta.page <= 1} onClick={() => void loadPlanDetail(planDetailKind, planDetailMeta.page - 1, planDetailAction)}>Anterior</button><span>Página {planDetailMeta.page} de {planDetailMeta.totalPages}</span><button type="button" disabled={planDetailMeta.page >= planDetailMeta.totalPages} onClick={() => void loadPlanDetail(planDetailKind, planDetailMeta.page + 1, planDetailAction)}>Siguiente</button></div>}</>}</div>}
|
{(planDetailKind || planDetailAction) && <div className="import-plan-detail"><div className="table-summary"><strong>{planDetailKind ? ENTITY_LABELS[planDetailKind] : planDetailAction ? PLAN_ACTION_LABELS[planDetailAction] : 'Detalle del plan'}</strong><span>{planDetailMeta.total.toLocaleString('es-AR')} elementos únicos</span></div>{planDetailLoading ? <LoadingBlock label="Cargando detalle del plan…" /> : <><div className="table-scroll"><SortableTable><thead><tr><th>Decisión</th><th>Entidad / relación</th><th>Qué hará el sistema</th><th>Filas fuente</th></tr></thead><tbody>{planDetailItems.map((item) => { const objectId = matchedObjectId(item); return <tr key={item.id}><td><span className={`status-badge ${item.action === 'REVIEW' ? 'observed' : item.action === 'CREATE' ? 'active' : item.action === 'MATCH' ? 'pending' : 'inactive'}`}>{PLAN_ACTION_LABELS[item.action]}</span></td><td><strong>{item.displayName}</strong><small className="cell-subtle">{ENTITY_LABELS[item.entityKind]}</small>{item.entityKind === 'DEPARTMENT' && <small className="cell-subtle">Código {String(item.payload.departmentCode ?? '—')}</small>}{item.entityKind === 'LEGAL_RIGHT' && <small className="cell-subtle">{String(item.payload.rightType ?? '—')} · estado inicial PENDING</small>}</td><td>{item.action === 'MATCH' ? <div className="import-match-explanation"><strong>Se reutiliza un registro del inventario</strong><span>{MATCH_CRITERIA[item.entityKind]}</span>{objectId && <code>ID {objectId.slice(0, 8)}…</code>}</div> : item.action === 'CREATE' ? <div className="import-match-explanation"><strong>Se creará un registro nuevo</strong><span>No se encontró una coincidencia exacta y segura con las reglas de este tipo.</span></div> : item.action === 'REVIEW' ? <div className="row-issue-list">{item.reviewCodes.slice(0, 3).map((code) => <span key={code}>{reviewGuidance(code).title}</span>)}</div> : <span className="cell-subtle">No se incorporará al inventario.</span>}</td><td>{sourceRowsLabel(item.sourceRowNumbers)}</td></tr>; })}</tbody></SortableTable></div>{planDetailMeta.totalPages > 1 && <div className="pagination"><button type="button" disabled={planDetailMeta.page <= 1} onClick={() => void loadPlanDetail(planDetailKind, planDetailMeta.page - 1, planDetailAction)}>Anterior</button><span>Página {planDetailMeta.page} de {planDetailMeta.totalPages}</span><button type="button" disabled={planDetailMeta.page >= planDetailMeta.totalPages} onClick={() => void loadPlanDetail(planDetailKind, planDetailMeta.page + 1, planDetailAction)}>Siguiente</button></div>}</>}</div>}
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
{plan.status === 'REVIEW_REQUIRED' && <div id="import-review-panel" className="import-review-panel">
|
{plan.status === 'REVIEW_REQUIRED' && <div id="import-review-panel" className="import-review-panel">
|
||||||
@@ -743,7 +744,7 @@ Escribí IMPORTAR para continuar:`);
|
|||||||
|
|
||||||
<div className="table-panel import-rows-table">
|
<div className="table-panel import-rows-table">
|
||||||
<div className="table-summary"><strong>Vista previa normalizada</strong><span>{rowMeta.total.toLocaleString('es-AR')} filas</span></div>
|
<div className="table-summary"><strong>Vista previa normalizada</strong><span>{rowMeta.total.toLocaleString('es-AR')} filas</span></div>
|
||||||
<div className="table-scroll"><table>
|
<div className="table-scroll"><SortableTable>
|
||||||
{detail.profileCode === 'MENDOZA_YACIMIENTOS_V1' ? <>
|
{detail.profileCode === 'MENDOZA_YACIMIENTOS_V1' ? <>
|
||||||
<thead><tr><th>Fila</th><th>Resultado</th><th>Área</th><th>Yacimiento</th><th>Departamento</th><th>Derecho</th><th>Operadora</th><th>Conciliación</th><th>Observaciones</th></tr></thead>
|
<thead><tr><th>Fila</th><th>Resultado</th><th>Área</th><th>Yacimiento</th><th>Departamento</th><th>Derecho</th><th>Operadora</th><th>Conciliación</th><th>Observaciones</th></tr></thead>
|
||||||
<tbody>{rows.map((row) => { const state = rowStatus(row.status); const rec = record(row.normalizedData.reconciliation); return <tr key={row.id}>
|
<tbody>{rows.map((row) => { const state = rowStatus(row.status); const rec = record(row.normalizedData.reconciliation); return <tr key={row.id}>
|
||||||
@@ -766,7 +767,7 @@ Escribí IMPORTAR para continuar:`);
|
|||||||
<td><div className="row-issue-list">{row.issues.length ? row.issues.slice(0, 3).map((issue) => <span key={issue}>{ISSUE_LABELS[issue] ?? issue}</span>) : <span className="ok-text">Sin observaciones</span>}{row.issues.length > 3 && <small>+{row.issues.length - 3}</small>}</div></td>
|
<td><div className="row-issue-list">{row.issues.length ? row.issues.slice(0, 3).map((issue) => <span key={issue}>{ISSUE_LABELS[issue] ?? issue}</span>) : <span className="ok-text">Sin observaciones</span>}{row.issues.length > 3 && <small>+{row.issues.length - 3}</small>}</div></td>
|
||||||
</tr>; })}</tbody>
|
</tr>; })}</tbody>
|
||||||
</>}
|
</>}
|
||||||
</table></div>
|
</SortableTable></div>
|
||||||
<div className="pagination"><button className="button secondary" disabled={rowMeta.page <= 1 || detailLoading} onClick={() => void loadDetail(detail.id, rowMeta.page - 1)}>Anterior</button><span>Página {rowMeta.page} de {Math.max(rowMeta.totalPages, 1)}</span><button className="button secondary" disabled={rowMeta.page >= rowMeta.totalPages || detailLoading} onClick={() => void loadDetail(detail.id, rowMeta.page + 1)}>Siguiente</button></div>
|
<div className="pagination"><button className="button secondary" disabled={rowMeta.page <= 1 || detailLoading} onClick={() => void loadDetail(detail.id, rowMeta.page - 1)}>Anterior</button><span>Página {rowMeta.page} de {Math.max(rowMeta.totalPages, 1)}</span><button className="button secondary" disabled={rowMeta.page >= rowMeta.totalPages || detailLoading} onClick={() => void loadDetail(detail.id, rowMeta.page + 1)}>Siguiente</button></div>
|
||||||
</div>
|
</div>
|
||||||
</>}
|
</>}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SortableTable } from '../components/SortableTable';
|
||||||
import { SearchableSelect } from '../components/SearchableSelect';
|
import { SearchableSelect } from '../components/SearchableSelect';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
@@ -185,7 +186,7 @@ export function AssetsPage() {
|
|||||||
? <EmptyState title="Todavía no hay registros" text={selectedPresentation ? `No hay registros cargados en ${selectedPresentation.title}.` : 'La base está lista para comenzar la carga manual desde Departamento.'} />
|
? <EmptyState title="Todavía no hay registros" text={selectedPresentation ? `No hay registros cargados en ${selectedPresentation.title}.` : 'La base está lista para comenzar la carga manual desde Departamento.'} />
|
||||||
: <div className="table-panel compact-assets-table">
|
: <div className="table-panel compact-assets-table">
|
||||||
<div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div>
|
<div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div>
|
||||||
<div className="table-scroll"><table><thead><tr><th>Registro</th><th>Sección</th><th>Ubicación / Operadora</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody>
|
<div className="table-scroll"><SortableTable><thead><tr><th>Registro</th><th>Sección</th><th>Ubicación / Operadora</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody>
|
||||||
{assets.map((asset) => <tr key={asset.id}>
|
{assets.map((asset) => <tr key={asset.id}>
|
||||||
<td><div className="asset-cell"><span className="asset-symbol"><Icon name="layers" size={16} /></span><div><Link to={`/inventarios/${asset.id}`} className="table-primary">{asset.name}</Link><small>{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}</small></div></div></td>
|
<td><div className="asset-cell"><span className="asset-symbol"><Icon name="layers" size={16} /></span><div><Link to={`/inventarios/${asset.id}`} className="table-primary">{asset.name}</Link><small>{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}</small></div></div></td>
|
||||||
<td><span className="tag">{asset.type.name}</span></td>
|
<td><span className="tag">{asset.type.name}</span></td>
|
||||||
@@ -194,7 +195,7 @@ export function AssetsPage() {
|
|||||||
<td>{formatDate(asset.updatedAt)}</td>
|
<td>{formatDate(asset.updatedAt)}</td>
|
||||||
<td className="action-cell"><Link className="icon-button" to={`/inventarios/${asset.id}`} aria-label={`Abrir ${asset.name}`}><Icon name="chevron" /></Link></td>
|
<td className="action-cell"><Link className="icon-button" to={`/inventarios/${asset.id}`} aria-label={`Abrir ${asset.name}`}><Icon name="chevron" /></Link></td>
|
||||||
</tr>)}
|
</tr>)}
|
||||||
</tbody></table></div>
|
</tbody></SortableTable></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>
|
<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>
|
||||||
</div>}
|
</div>}
|
||||||
</section>;
|
</section>;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SortableTable } from '../components/SortableTable';
|
||||||
import { SearchableSelect } from '../components/SearchableSelect';
|
import { SearchableSelect } from '../components/SearchableSelect';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
@@ -62,7 +63,7 @@ export function AuditPage() {
|
|||||||
<div className="filter-actions"><button className="button text" type="button" onClick={clear}>Limpiar</button><button className="button primary">Aplicar filtros</button></div>
|
<div className="filter-actions"><button className="button text" type="button" onClick={clear}>Limpiar</button><button className="button primary">Aplicar filtros</button></div>
|
||||||
</form>
|
</form>
|
||||||
{error && <Alert>{error}</Alert>}
|
{error && <Alert>{error}</Alert>}
|
||||||
{loading ? <LoadingBlock label="Consultando auditoría…" /> : events.length === 0 ? <EmptyState title="Sin eventos" text="No hay registros para los filtros seleccionados." /> : <div className="table-panel audit-table"><div className="table-scroll"><table><thead><tr><th>Fecha y hora</th><th>Usuario</th><th>Acción</th><th>Entidad</th><th>Origen</th><th>Request ID</th><th /></tr></thead><tbody>{events.map((event) => <tr key={event.id}><td>{formatDate(event.occurredAt)}</td><td><strong>{event.actorUsername ?? 'Sistema'}</strong><small className="cell-subtext">{event.ip ?? 'IP no registrada'}</small></td><td><span className={`event-badge ${event.action.includes('FAILED') || event.action.includes('REUSE') ? 'warning' : ''}`}>{actionLabel(event.action)}</span><small className="cell-subtext code-text">{event.action}</small></td><td>{event.entityType ?? '—'}<small className="cell-subtext code-text">{event.entityId ?? ''}</small></td><td><span className="tag">{event.source}</span></td><td><code>{event.requestId ? `${event.requestId.slice(0, 12)}…` : '—'}</code></td><td><button className="icon-button" onClick={() => openDetail(event.id)} aria-label="Ver detalle"><Icon name="chevron" /></button></td></tr>)}</tbody></table></div><div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>Página {page} de {Math.max(meta.totalPages, 1)}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
|
{loading ? <LoadingBlock label="Consultando auditoría…" /> : events.length === 0 ? <EmptyState title="Sin eventos" text="No hay registros para los filtros seleccionados." /> : <div className="table-panel audit-table"><div className="table-scroll"><SortableTable><thead><tr><th>Fecha y hora</th><th>Usuario</th><th>Acción</th><th>Entidad</th><th>Origen</th><th>Request ID</th><th /></tr></thead><tbody>{events.map((event) => <tr key={event.id}><td>{formatDate(event.occurredAt)}</td><td><strong>{event.actorUsername ?? 'Sistema'}</strong><small className="cell-subtext">{event.ip ?? 'IP no registrada'}</small></td><td><span className={`event-badge ${event.action.includes('FAILED') || event.action.includes('REUSE') ? 'warning' : ''}`}>{actionLabel(event.action)}</span><small className="cell-subtext code-text">{event.action}</small></td><td>{event.entityType ?? '—'}<small className="cell-subtext code-text">{event.entityId ?? ''}</small></td><td><span className="tag">{event.source}</span></td><td><code>{event.requestId ? `${event.requestId.slice(0, 12)}…` : '—'}</code></td><td><button className="icon-button" onClick={() => openDetail(event.id)} aria-label="Ver detalle"><Icon name="chevron" /></button></td></tr>)}</tbody></SortableTable></div><div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>Página {page} de {Math.max(meta.totalPages, 1)}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
|
||||||
|
|
||||||
{(detailLoading || detail) && <div className="modal-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget && !detailLoading) setDetail(null); }}><aside className="detail-drawer" role="dialog" aria-modal="true" aria-label="Detalle de auditoría">{detailLoading ? <LoadingBlock label="Cargando detalle…" /> : detail && <><div className="drawer-heading"><div><span className="eyebrow">EVENTO DE AUDITORÍA</span><h2>{actionLabel(detail.action)}</h2></div><button className="icon-button" onClick={() => setDetail(null)} aria-label="Cerrar">×</button></div><dl className="detail-list"><div><dt>Fecha</dt><dd>{formatDate(detail.occurredAt)}</dd></div><div><dt>Usuario</dt><dd>{detail.actorUsername ?? 'Sistema'}</dd></div><div><dt>Origen / IP</dt><dd>{detail.source} · {detail.ip ?? '—'}</dd></div><div><dt>Entidad</dt><dd>{detail.entityType ?? '—'} {detail.entityId ? `· ${detail.entityId}` : ''}</dd></div><div><dt>Request ID</dt><dd><code>{detail.requestId ?? '—'}</code></dd></div><div><dt>User agent</dt><dd>{detail.userAgent ?? '—'}</dd></div></dl><JsonDetail title="Datos anteriores" value={detail.beforeData} /><JsonDetail title="Datos posteriores" value={detail.afterData} /><JsonDetail title="Metadatos" value={detail.metadata} /></>}</aside></div>}
|
{(detailLoading || detail) && <div className="modal-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget && !detailLoading) setDetail(null); }}><aside className="detail-drawer" role="dialog" aria-modal="true" aria-label="Detalle de auditoría">{detailLoading ? <LoadingBlock label="Cargando detalle…" /> : detail && <><div className="drawer-heading"><div><span className="eyebrow">EVENTO DE AUDITORÍA</span><h2>{actionLabel(detail.action)}</h2></div><button className="icon-button" onClick={() => setDetail(null)} aria-label="Cerrar">×</button></div><dl className="detail-list"><div><dt>Fecha</dt><dd>{formatDate(detail.occurredAt)}</dd></div><div><dt>Usuario</dt><dd>{detail.actorUsername ?? 'Sistema'}</dd></div><div><dt>Origen / IP</dt><dd>{detail.source} · {detail.ip ?? '—'}</dd></div><div><dt>Entidad</dt><dd>{detail.entityType ?? '—'} {detail.entityId ? `· ${detail.entityId}` : ''}</dd></div><div><dt>Request ID</dt><dd><code>{detail.requestId ?? '—'}</code></dd></div><div><dt>User agent</dt><dd>{detail.userAgent ?? '—'}</dd></div></dl><JsonDetail title="Datos anteriores" value={detail.beforeData} /><JsonDetail title="Datos posteriores" value={detail.afterData} /><JsonDetail title="Metadatos" value={detail.metadata} /></>}</aside></div>}
|
||||||
</section>;
|
</section>;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SortableTable } from '../components/SortableTable';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { Link, useSearchParams } from 'react-router';
|
import { Link, useSearchParams } from 'react-router';
|
||||||
@@ -405,7 +406,7 @@ export function CompanyInventoryPage({ initialAsset }: { initialAsset: AssetDeta
|
|||||||
<div className="company-card-heading company-doc-heading"><div><span className="eyebrow">DOCUMENTACIÓN EMITIDA</span><h2>Actas e informes</h2><p>Historial documental de esta empresa. Sólo se muestran Actas ya emitidas y sus Informes asociados.</p></div><div className="company-doc-counts"><span>{acts.length} actas</span><span>{reports.length} informes</span></div></div>
|
<div className="company-card-heading company-doc-heading"><div><span className="eyebrow">DOCUMENTACIÓN EMITIDA</span><h2>Actas e informes</h2><p>Historial documental de esta empresa. Sólo se muestran Actas ya emitidas y sus Informes asociados.</p></div><div className="company-doc-counts"><span>{acts.length} actas</span><span>{reports.length} informes</span></div></div>
|
||||||
<label className="company-document-search"><Icon name="search" /><input value={documentSearch} onChange={(event) => setDocumentSearch(event.target.value)} placeholder="Buscar por acta, informe, inspección, área o GEDO" /></label>
|
<label className="company-document-search"><Icon name="search" /><input value={documentSearch} onChange={(event) => setDocumentSearch(event.target.value)} placeholder="Buscar por acta, informe, inspección, área o GEDO" /></label>
|
||||||
{documentsError && <Alert>{documentsError}</Alert>}
|
{documentsError && <Alert>{documentsError}</Alert>}
|
||||||
{documentsLoading ? <LoadingBlock label="Cargando documentación emitida…" /> : filteredActs.length === 0 ? <EmptyState title="Sin documentación emitida" text="Todavía no hay Actas selladas para esta empresa." /> : <div className="table-scroll company-doc-table"><table><thead><tr><th>Fecha</th><th>Área</th><th>Acta</th><th>Inspección</th><th>Informe</th><th>Oficialización</th><th /></tr></thead><tbody>{filteredActs.map((act) => {
|
{documentsLoading ? <LoadingBlock label="Cargando documentación emitida…" /> : filteredActs.length === 0 ? <EmptyState title="Sin documentación emitida" text="Todavía no hay Actas selladas para esta empresa." /> : <div className="table-scroll company-doc-table"><SortableTable><thead><tr><th>Fecha</th><th>Área</th><th>Acta</th><th>Inspección</th><th>Informe</th><th>Oficialización</th><th /></tr></thead><tbody>{filteredActs.map((act) => {
|
||||||
const report = reportByAct.get(act.id);
|
const report = reportByAct.get(act.id);
|
||||||
return <tr key={act.id}>
|
return <tr key={act.id}>
|
||||||
<td>{formatDate(act.occurredAt)}</td>
|
<td>{formatDate(act.occurredAt)}</td>
|
||||||
@@ -416,7 +417,7 @@ export function CompanyInventoryPage({ initialAsset }: { initialAsset: AssetDeta
|
|||||||
<td>{report ? <span className={`status-badge ${reportStatusClass(report)}`}>{reportStatusLabel(report)}</span> : <span className="muted">—</span>}</td>
|
<td>{report ? <span className={`status-badge ${reportStatusClass(report)}`}>{reportStatusLabel(report)}</span> : <span className="muted">—</span>}</td>
|
||||||
<td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><Icon name="chevron" /></Link></td>
|
<td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><Icon name="chevron" /></Link></td>
|
||||||
</tr>;
|
</tr>;
|
||||||
})}</tbody></table></div>}
|
})}</tbody></SortableTable></div>}
|
||||||
</article>
|
</article>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,6 @@ interface PublicFinding {
|
|||||||
recurrenceOfFindingId: string | null;
|
recurrenceOfFindingId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PublicInventory {
|
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
typeName: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PublicSignatureView {
|
interface PublicSignatureView {
|
||||||
invitation: {
|
invitation: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -41,7 +34,6 @@ interface PublicSignatureView {
|
|||||||
documentNumber: string | null;
|
documentNumber: string | null;
|
||||||
position: string | null;
|
position: string | null;
|
||||||
};
|
};
|
||||||
inventories: PublicInventory[];
|
|
||||||
findings: PublicFinding[];
|
findings: PublicFinding[];
|
||||||
consent: string;
|
consent: string;
|
||||||
allowedActions: string[];
|
allowedActions: string[];
|
||||||
@@ -351,12 +343,6 @@ export function CompanySignaturePage() {
|
|||||||
<h2>Contenido del Acta</h2>
|
<h2>Contenido del Acta</h2>
|
||||||
{view.act.summary && <><strong>Resumen</strong><p>{view.act.summary}</p></>}
|
{view.act.summary && <><strong>Resumen</strong><p>{view.act.summary}</p></>}
|
||||||
{view.act.observations && <><strong>Observaciones</strong><p>{view.act.observations}</p></>}
|
{view.act.observations && <><strong>Observaciones</strong><p>{view.act.observations}</p></>}
|
||||||
<h3>Inventario inspeccionado</h3>
|
|
||||||
{view.inventories.length === 0 ? <p>Sin Inventario detallado.</p> : view.inventories.map((item) => (
|
|
||||||
<div key={item.id} style={{ padding: '9px 0', borderBottom: '1px solid #e3e9ed' }}>
|
|
||||||
<strong>{item.code} · {item.name}</strong>{item.typeName && <div style={{ color: '#5c707b' }}>{item.typeName}</div>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<h3 style={{ marginTop: 24 }}>Hallazgos</h3>
|
<h3 style={{ marginTop: 24 }}>Hallazgos</h3>
|
||||||
{view.findings.length === 0 ? <p>El Acta no contiene Hallazgos.</p> : view.findings.map((finding) => (
|
{view.findings.length === 0 ? <p>El Acta no contiene Hallazgos.</p> : view.findings.map((finding) => (
|
||||||
<article key={finding.id} style={{ padding: 14, marginBottom: 10, border: '1px solid #d8e1e7', borderRadius: 10 }}>
|
<article key={finding.id} style={{ padding: 14, marginBottom: 10, border: '1px solid #d8e1e7', borderRadius: 10 }}>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user