Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1beba14c3b | ||
|
|
fa6ba7f31e | ||
|
|
a414d0ed36 | ||
|
|
0b731b722f | ||
|
|
678b0f1792 | ||
|
|
c71afab9af | ||
|
|
29208f4e1f | ||
|
|
4523aef932 | ||
|
|
011b3e7fcb | ||
|
|
9b7d67bea2 | ||
|
|
f0b92c00e1 | ||
|
|
cab9cd7c01 | ||
|
|
c9575cc520 |
@@ -1,5 +1,5 @@
|
||||
name: Android CI / RC
|
||||
# F6.8 offline/document barrier: lint + real tests + debug artifact + release compile.
|
||||
# F6.14 yacimiento-scope barrier: lint + real tests + debug artifact + release compile.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
|
||||
sha256sum "$apk" > "${apk}.sha256"
|
||||
{
|
||||
echo "phase=F6.8"
|
||||
echo "phase=F6.14"
|
||||
echo "version=$version"
|
||||
echo "versionCode=$code"
|
||||
echo "commit=$GITHUB_SHA"
|
||||
|
||||
+10
-3
@@ -1,9 +1,9 @@
|
||||
# DH Inspección Android · release de campo 0.19.11
|
||||
# DH Inspección Android · release de campo 0.19.13
|
||||
|
||||
## Candidata vigente
|
||||
|
||||
- `versionName`: **0.19.11**; `versionCode`: **39**.
|
||||
- API: `https://dhv2.korexlabs.com/api/v3/`; compatible con API 0.29.0-9 / WEB 0.23.0-6.
|
||||
- `versionName`: **0.19.13**; `versionCode`: **41**.
|
||||
- API: `https://dhv2.korexlabs.com/api/v3/`; compatible con API 0.29.0-14 / WEB 0.23.0-10.
|
||||
- Antes de cerrar el contenido, el inspector escribe una descripción real de lo actuado.
|
||||
- La descripción se sincroniza antes del bloqueo, también cuando se trabajó sin conexión.
|
||||
- Un acta anterior, ya sellada, conserva el texto originalmente registrado.
|
||||
@@ -161,3 +161,10 @@ Esta candidata requiere conexión. No implementa trabajo offline ni cola persist
|
||||
- El borrador de Acta nace vacío, sin Inventario y sin urgencia predeterminada.
|
||||
- La urgencia se exige al cerrar el Acta y se persiste en la misma operación que la vuelve inmutable.
|
||||
- `inspection_acts.urgency` puede quedar pendiente durante el borrador; el bloqueo exige la decisión y calcula el vencimiento desde allí.
|
||||
|
||||
## F6.14 · alcance territorial móvil
|
||||
|
||||
- Una nueva Inspección abierta desde la APK exige seleccionar Departamento → Área → Yacimiento → Operadora.
|
||||
- El Yacimiento elegido queda congelado como alcance de la Inspección y del Acta.
|
||||
- Los Hallazgos pueden registrarse sobre ese Yacimiento, sus Instalaciones o sus Subinstalaciones; nunca fuera de esa rama.
|
||||
- Al abrir una Inspección que ya está en curso, la APK entra directamente a Actas y Hallazgos.
|
||||
|
||||
@@ -14,8 +14,8 @@ android {
|
||||
applicationId = "com.korexlabs.dhinspeccion"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 39
|
||||
versionName = "0.19.11"
|
||||
versionCode = 41
|
||||
versionName = "0.19.13"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
+37
-11
@@ -25,22 +25,36 @@ data class MobilePlanningAssetResponse(
|
||||
)
|
||||
|
||||
data class OpenMobileInspectionRequest(
|
||||
val departmentId: String,
|
||||
val operationalAreaId: String,
|
||||
val scopeAssetId: String,
|
||||
val operatorCompanyId: String,
|
||||
)
|
||||
|
||||
private interface MobileInspectionOpenApi {
|
||||
@GET("inspection-visits/mobile/planning-context/areas")
|
||||
suspend fun areas(
|
||||
@GET("inspection-visits/mobile/planning-context/departments")
|
||||
suspend fun departments(
|
||||
@Header("Authorization") authorization: String,
|
||||
): MobilePlanningAssetResponse
|
||||
|
||||
@GET("inspection-visits/mobile/planning-context/areas/{areaId}/operators")
|
||||
suspend fun operators(
|
||||
@GET("inspection-visits/mobile/planning-context/departments/{departmentId}/areas")
|
||||
suspend fun areas(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("departmentId") departmentId: String,
|
||||
): MobilePlanningAssetResponse
|
||||
|
||||
@GET("inspection-visits/mobile/planning-context/areas/{areaId}/yacimientos")
|
||||
suspend fun yacimientos(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("areaId") areaId: String,
|
||||
): MobilePlanningAssetResponse
|
||||
|
||||
@GET("inspection-visits/mobile/planning-context/yacimientos/{yacimientoId}/operators")
|
||||
suspend fun operators(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("yacimientoId") yacimientoId: String,
|
||||
): MobilePlanningAssetResponse
|
||||
|
||||
@POST("inspection-visits/mobile/open")
|
||||
suspend fun open(
|
||||
@Header("Authorization") authorization: String,
|
||||
@@ -61,18 +75,31 @@ class MobileInspectionOpenRepository(context: Context) {
|
||||
.build()
|
||||
.create(MobileInspectionOpenApi::class.java)
|
||||
|
||||
suspend fun areas(): MobilePlanningAssetResponse = authorized { session ->
|
||||
api.areas("Bearer ${session.accessToken}")
|
||||
suspend fun departments(): MobilePlanningAssetResponse = authorized { session ->
|
||||
api.departments("Bearer ${session.accessToken}")
|
||||
}
|
||||
|
||||
suspend fun operators(areaId: String): MobilePlanningAssetResponse = authorized { session ->
|
||||
api.operators("Bearer ${session.accessToken}", areaId)
|
||||
suspend fun areas(departmentId: String): MobilePlanningAssetResponse = authorized { session ->
|
||||
api.areas("Bearer ${session.accessToken}", departmentId)
|
||||
}
|
||||
|
||||
suspend fun open(areaId: String, companyId: String): VisitDetail = authorized { session ->
|
||||
suspend fun yacimientos(areaId: String): MobilePlanningAssetResponse = authorized { session ->
|
||||
api.yacimientos("Bearer ${session.accessToken}", areaId)
|
||||
}
|
||||
|
||||
suspend fun operators(yacimientoId: String): MobilePlanningAssetResponse = authorized { session ->
|
||||
api.operators("Bearer ${session.accessToken}", yacimientoId)
|
||||
}
|
||||
|
||||
suspend fun open(
|
||||
departmentId: String,
|
||||
areaId: String,
|
||||
yacimientoId: String,
|
||||
companyId: String,
|
||||
): VisitDetail = authorized { session ->
|
||||
api.open(
|
||||
"Bearer ${session.accessToken}",
|
||||
OpenMobileInspectionRequest(areaId, companyId),
|
||||
OpenMobileInspectionRequest(departmentId, areaId, yacimientoId, companyId),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,5 +116,4 @@ class MobileInspectionOpenRepository(context: Context) {
|
||||
|
||||
private suspend fun refresh(previous: StoredSession): StoredSession =
|
||||
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
|
||||
|
||||
}
|
||||
|
||||
@@ -47,19 +47,41 @@ fun MobileHomeScreen(model: MainViewModel) {
|
||||
val openRepository = remember(context) { MobileInspectionOpenRepository(context) }
|
||||
|
||||
var showOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var departments by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
|
||||
var areas by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
|
||||
var yacimientos by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
|
||||
var operators by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
|
||||
var selectedDepartmentId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var selectedAreaId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var selectedYacimientoId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var selectedCompanyId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var loadingContext by remember { mutableStateOf(false) }
|
||||
var opening by remember { mutableStateOf(false) }
|
||||
var localError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun loadAreas() {
|
||||
fun loadDepartments() {
|
||||
scope.launch {
|
||||
loadingContext = true
|
||||
localError = null
|
||||
runCatching { openRepository.areas().data }
|
||||
runCatching { openRepository.departments().data }
|
||||
.onSuccess { departments = it }
|
||||
.onFailure { localError = DhRepository.humanError(it) }
|
||||
loadingContext = false
|
||||
}
|
||||
}
|
||||
|
||||
fun chooseDepartment(departmentId: String) {
|
||||
selectedDepartmentId = departmentId
|
||||
selectedAreaId = null
|
||||
selectedYacimientoId = null
|
||||
selectedCompanyId = null
|
||||
areas = emptyList()
|
||||
yacimientos = emptyList()
|
||||
operators = emptyList()
|
||||
scope.launch {
|
||||
loadingContext = true
|
||||
localError = null
|
||||
runCatching { openRepository.areas(departmentId).data }
|
||||
.onSuccess { areas = it }
|
||||
.onFailure { localError = DhRepository.humanError(it) }
|
||||
loadingContext = false
|
||||
@@ -68,20 +90,39 @@ fun MobileHomeScreen(model: MainViewModel) {
|
||||
|
||||
fun chooseArea(areaId: String) {
|
||||
selectedAreaId = areaId
|
||||
selectedYacimientoId = null
|
||||
selectedCompanyId = null
|
||||
yacimientos = emptyList()
|
||||
operators = emptyList()
|
||||
scope.launch {
|
||||
loadingContext = true
|
||||
localError = null
|
||||
runCatching { openRepository.yacimientos(areaId).data }
|
||||
.onSuccess { yacimientos = it }
|
||||
.onFailure { localError = DhRepository.humanError(it) }
|
||||
loadingContext = false
|
||||
}
|
||||
}
|
||||
|
||||
fun chooseYacimiento(yacimientoId: String) {
|
||||
selectedYacimientoId = yacimientoId
|
||||
selectedCompanyId = null
|
||||
operators = emptyList()
|
||||
scope.launch {
|
||||
loadingContext = true
|
||||
localError = null
|
||||
runCatching { openRepository.operators(areaId).data }
|
||||
.onSuccess { operators = it }
|
||||
runCatching { openRepository.operators(yacimientoId).data }
|
||||
.onSuccess {
|
||||
operators = it
|
||||
if (it.size == 1) selectedCompanyId = it.first().id
|
||||
}
|
||||
.onFailure { localError = DhRepository.humanError(it) }
|
||||
loadingContext = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(showOpen) {
|
||||
if (showOpen && areas.isEmpty()) loadAreas()
|
||||
if (showOpen && departments.isEmpty()) loadDepartments()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
|
||||
@@ -142,27 +183,57 @@ fun MobileHomeScreen(model: MainViewModel) {
|
||||
) {
|
||||
Text("Abrir inspección en campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Elegí el Área y la Operadora vigente. La inspección se crea autoasignada a vos y queda iniciada con la fecha y hora del servidor.",
|
||||
"Definí el contexto completo antes de abrir el Acta: Departamento → Área → Yacimiento → Operadora. La inspección queda autoasignada a vos y se inicia con la hora del servidor.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
Text("1. Área", fontWeight = FontWeight.SemiBold)
|
||||
if (loadingContext && areas.isEmpty()) CircularProgressIndicator()
|
||||
Text("1. Departamento", fontWeight = FontWeight.SemiBold)
|
||||
if (loadingContext && departments.isEmpty()) CircularProgressIndicator()
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(areas, key = { it.id }) { area ->
|
||||
items(departments, key = { it.id }) { department ->
|
||||
AssistChip(
|
||||
onClick = { chooseArea(area.id) },
|
||||
label = { Text(if (area.id == selectedAreaId) "✓ ${area.name}" else area.name) },
|
||||
onClick = { chooseDepartment(department.id) },
|
||||
label = { Text(if (department.id == selectedDepartmentId) "✓ ${department.name}" else department.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedDepartmentId != null) {
|
||||
Text("2. Área", fontWeight = FontWeight.SemiBold)
|
||||
if (loadingContext && areas.isEmpty()) CircularProgressIndicator()
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(areas, key = { it.id }) { area ->
|
||||
AssistChip(
|
||||
onClick = { chooseArea(area.id) },
|
||||
label = { Text(if (area.id == selectedAreaId) "✓ ${area.name}" else area.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedAreaId != null) {
|
||||
Text("2. Operadora", fontWeight = FontWeight.SemiBold)
|
||||
Text("3. Yacimiento", fontWeight = FontWeight.SemiBold)
|
||||
if (loadingContext && yacimientos.isEmpty()) {
|
||||
CircularProgressIndicator()
|
||||
} else if (yacimientos.isEmpty()) {
|
||||
Text("No hay Yacimientos disponibles para el Área seleccionada.", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(yacimientos, key = { it.id }) { yacimiento ->
|
||||
AssistChip(
|
||||
onClick = { chooseYacimiento(yacimiento.id) },
|
||||
label = { Text(if (yacimiento.id == selectedYacimientoId) "✓ ${yacimiento.name}" else yacimiento.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedYacimientoId != null) {
|
||||
Text("4. Operadora", fontWeight = FontWeight.SemiBold)
|
||||
if (loadingContext && operators.isEmpty()) {
|
||||
CircularProgressIndicator()
|
||||
} else if (operators.isEmpty()) {
|
||||
Text("No hay una Operadora vigente para el Área seleccionada.", color = MaterialTheme.colorScheme.error)
|
||||
Text("El Yacimiento no tiene una Operadora válida configurada.", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(operators, key = { it.id }) { company ->
|
||||
@@ -176,12 +247,14 @@ fun MobileHomeScreen(model: MainViewModel) {
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val departmentId = selectedDepartmentId ?: return@Button
|
||||
val areaId = selectedAreaId ?: return@Button
|
||||
val yacimientoId = selectedYacimientoId ?: return@Button
|
||||
val companyId = selectedCompanyId ?: return@Button
|
||||
scope.launch {
|
||||
opening = true
|
||||
localError = null
|
||||
runCatching { openRepository.open(areaId, companyId) }
|
||||
runCatching { openRepository.open(departmentId, areaId, yacimientoId, companyId) }
|
||||
.onSuccess { opened ->
|
||||
showOpen = false
|
||||
model.openVisit(opened.id)
|
||||
@@ -191,9 +264,9 @@ fun MobileHomeScreen(model: MainViewModel) {
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = selectedAreaId != null && selectedCompanyId != null && !opening && !loadingContext,
|
||||
enabled = selectedDepartmentId != null && selectedAreaId != null && selectedYacimientoId != null && selectedCompanyId != null && !opening && !loadingContext,
|
||||
) {
|
||||
Text(if (opening) "Abriendo…" else "Abrir inspección ahora")
|
||||
Text(if (opening) "Abriendo…" else "Abrir inspección y continuar al Acta")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,6 +301,7 @@ private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
|
||||
Text(visit.code, fontWeight = FontWeight.Bold)
|
||||
Text(visitStatusLabelEs(visit.status))
|
||||
}
|
||||
Text(visit.scopeAsset?.name ?: "Yacimiento sin definir", fontWeight = FontWeight.SemiBold)
|
||||
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
|
||||
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
|
||||
visit.plannedStartAt?.let {
|
||||
|
||||
+1
-1
@@ -261,7 +261,7 @@ fun ModernMobileActsScreen(
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Elegí una Instalación o Subinstalación existente, o creala en campo si todavía no está registrada.",
|
||||
"Elegí el Yacimiento, una Instalación o una Subinstalación. El Hallazgo siempre queda dentro del Yacimiento de esta Acta.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
|
||||
|
||||
@@ -107,7 +107,9 @@ private data class ModernGeoSnapshot(
|
||||
@Composable
|
||||
fun ModernVisitRoot(model: MainViewModel) {
|
||||
val visit = model.visit ?: return
|
||||
var screenName by rememberSaveable(visit.id) { mutableStateOf(ModernVisitScreen.OVERVIEW.name) }
|
||||
var screenName by rememberSaveable(visit.id) {
|
||||
mutableStateOf(if (visit.status == "IN_PROGRESS") ModernVisitScreen.ACTS.name else ModernVisitScreen.OVERVIEW.name)
|
||||
}
|
||||
val screen = runCatching { ModernVisitScreen.valueOf(screenName) }.getOrDefault(ModernVisitScreen.OVERVIEW)
|
||||
|
||||
when (screen) {
|
||||
@@ -938,7 +940,7 @@ private fun ModernInventoryBrowse(
|
||||
OutlinedTextField(
|
||||
value = search,
|
||||
onValueChange = onSearchChange,
|
||||
label = { Text("Buscar Instalación o Subinstalación") },
|
||||
label = { Text("Buscar Yacimiento, Instalación o Subinstalación") },
|
||||
placeholder = { Text("Nombre, código o dato técnico") },
|
||||
leadingIcon = { Icon(Icons.Filled.Search, null) },
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
|
||||
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
|
||||
@Test
|
||||
fun debugBuildKeepsSeparateApplicationIdentity() {
|
||||
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
|
||||
assertEquals(39, BuildConfig.VERSION_CODE)
|
||||
assertEquals("0.19.11-debug", BuildConfig.VERSION_NAME)
|
||||
assertEquals(41, BuildConfig.VERSION_CODE)
|
||||
assertEquals("0.19.13-debug", BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-11",
|
||||
"version": "0.29.0-17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-11",
|
||||
"version": "0.29.0-17",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-11",
|
||||
"version": "0.29.0-17",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
|
||||
@@ -18,5 +18,6 @@ import { FieldBriefingService } from './field-briefing.service';
|
||||
FieldBriefingController,
|
||||
],
|
||||
providers: [ActAdministrationService, FieldBriefingService],
|
||||
exports: [ActAdministrationService],
|
||||
})
|
||||
export class ActAdministrationModule {}
|
||||
|
||||
@@ -114,17 +114,46 @@ export class ActAdministrationService {
|
||||
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 rows = await this.dataSource.query(
|
||||
`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"`,
|
||||
[randomUUID(), actId, dto.responseDueOn, dto.reason, principal.userId],
|
||||
);
|
||||
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 } });
|
||||
return rows[0];
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const rows = await manager.query(
|
||||
`INSERT INTO inspection_act_deadline_events (id, act_id, report_id, response_due_on, reason, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
RETURNING id, report_id AS "reportId", response_due_on AS "responseDueOn", reason, created_at AS "createdAt"`,
|
||||
[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);
|
||||
let storedName: string | null = null;
|
||||
let sha256: string | null = null;
|
||||
@@ -138,12 +167,12 @@ export class ActAdministrationService {
|
||||
}
|
||||
try {
|
||||
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)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
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"`,
|
||||
[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],
|
||||
`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,$14)
|
||||
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, 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];
|
||||
} catch (error) {
|
||||
if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined);
|
||||
@@ -151,8 +180,13 @@ export class ActAdministrationService {
|
||||
}
|
||||
}
|
||||
|
||||
async responseContent(responseId: 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]);
|
||||
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 AND ($2::uuid IS NULL OR report_id=$2::uuid)`,
|
||||
[responseId, reportId ?? null],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'PDF de respuesta inexistente.' });
|
||||
const filePath = join(STORAGE, row.storedName);
|
||||
|
||||
@@ -63,3 +63,25 @@ export class MapAssetsController {
|
||||
return this.geometries.map(query);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('map/context')
|
||||
export class MapOperationalContextController {
|
||||
constructor(private readonly geometries: AssetGeometriesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('assets.read')
|
||||
map() {
|
||||
return this.geometries.mapOperationalContext();
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('map/documents')
|
||||
export class MapDocumentsController {
|
||||
constructor(private readonly geometries: AssetGeometriesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('assets.read', 'inspection_acts.read', 'inspection_findings.read')
|
||||
map() {
|
||||
return this.geometries.mapDocuments();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,8 @@ export class AssetGeometriesService {
|
||||
geometry: row.geometry,
|
||||
properties: {
|
||||
id: row.id,
|
||||
entityId: row.id,
|
||||
entityKind: 'ASSET',
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
typeId: row.typeId,
|
||||
@@ -242,12 +244,230 @@ export class AssetGeometriesService {
|
||||
accuracyM: row.accuracyM == null ? null : Number(row.accuracyM),
|
||||
capturedAt: row.capturedAt,
|
||||
updatedAt: row.updatedAt,
|
||||
href: `/inventarios/${row.id}`,
|
||||
contextLine: row.parentName ? `Depende de ${row.parentName}` : null,
|
||||
},
|
||||
})),
|
||||
meta: { count: visible.length, truncated },
|
||||
};
|
||||
}
|
||||
|
||||
async mapOperationalContext() {
|
||||
const rows = (await this.dataSource.query(`
|
||||
WITH RECURSIVE y_tree AS (
|
||||
SELECT y.id AS yacimiento_id, y.id AS asset_id
|
||||
FROM assets y
|
||||
INNER JOIN asset_types y_type ON y_type.id=y.asset_type_id
|
||||
WHERE lower(y_type.code)='yacimiento'
|
||||
AND y_type.is_active=true
|
||||
AND y.information_status<>'INACTIVE'
|
||||
UNION ALL
|
||||
SELECT tree.yacimiento_id, child.id
|
||||
FROM y_tree tree
|
||||
INNER JOIN assets child ON child.parent_id=tree.asset_id
|
||||
WHERE child.information_status<>'INACTIVE'
|
||||
), y_loc AS (
|
||||
SELECT tree.yacimiento_id,
|
||||
ST_Centroid(ST_Collect(geometry.geometry)) AS geometry,
|
||||
MAX(geometry.updated_at) AS updated_at,
|
||||
COUNT(*)::integer AS source_geometries
|
||||
FROM y_tree tree
|
||||
INNER JOIN asset_geometries geometry ON geometry.asset_id=tree.asset_id
|
||||
GROUP BY tree.yacimiento_id
|
||||
), base AS (
|
||||
SELECT
|
||||
y.id AS yacimiento_id,y.code AS yacimiento_code,y.name AS yacimiento_name,
|
||||
y.information_status AS yacimiento_status,
|
||||
area.id AS area_id,area.code AS area_code,area.name AS area_name,
|
||||
department.id AS department_id,department.code AS department_code,department.name AS department_name,
|
||||
company.id AS company_id,company.code AS company_code,COALESCE(profile.legal_name,company.name) AS company_name,
|
||||
loc.geometry,loc.updated_at,loc.source_geometries
|
||||
FROM y_loc loc
|
||||
INNER JOIN assets y ON y.id=loc.yacimiento_id
|
||||
LEFT JOIN assets area ON area.id=y.parent_id
|
||||
LEFT JOIN assets department ON department.id=area.parent_id
|
||||
LEFT JOIN assets company ON company.id=y.operator_company_id
|
||||
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
|
||||
)
|
||||
SELECT
|
||||
'YACIMIENTO:'||base.yacimiento_id::text AS "featureId",
|
||||
base.yacimiento_id AS "entityId",'YACIMIENTO'::text AS "entityKind",
|
||||
ST_AsGeoJSON(base.geometry)::jsonb AS geometry,
|
||||
base.yacimiento_code AS code,base.yacimiento_name AS name,
|
||||
'Yacimiento'::text AS "typeName",base.yacimiento_status::text AS "informationStatus",
|
||||
'POINT'::text AS "geometryType",base.updated_at AS "updatedAt",
|
||||
'/inventarios/'||base.yacimiento_id::text AS href,
|
||||
concat_ws(' · ',base.department_name,base.area_name,base.company_name) AS "contextLine",
|
||||
base.department_name AS "departmentName",base.area_name AS "areaName",
|
||||
base.yacimiento_name AS "yacimientoName",base.company_name AS "companyName",
|
||||
base.source_geometries AS "sourceGeometries"
|
||||
FROM base
|
||||
UNION ALL
|
||||
SELECT
|
||||
'COMPANY:'||base.company_id::text||':'||base.yacimiento_id::text AS "featureId",
|
||||
base.company_id AS "entityId",'COMPANY'::text AS "entityKind",
|
||||
ST_AsGeoJSON(base.geometry)::jsonb AS geometry,
|
||||
base.company_code AS code,base.company_name AS name,
|
||||
'Empresa operadora'::text AS "typeName",'ACTIVE'::text AS "informationStatus",
|
||||
'POINT'::text AS "geometryType",base.updated_at AS "updatedAt",
|
||||
'/inventarios/'||base.company_id::text AS href,
|
||||
concat_ws(' · ','Yacimiento '||base.yacimiento_name,'Área '||base.area_name,base.department_name) AS "contextLine",
|
||||
base.department_name AS "departmentName",base.area_name AS "areaName",
|
||||
base.yacimiento_name AS "yacimientoName",base.company_name AS "companyName",
|
||||
base.source_geometries AS "sourceGeometries"
|
||||
FROM base
|
||||
WHERE base.company_id IS NOT NULL
|
||||
ORDER BY "entityKind","name","code"
|
||||
`)) as Array<Record<string, unknown>>;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: rows.map((row) => ({
|
||||
type: 'Feature' as const,
|
||||
id: row.featureId,
|
||||
geometry: row.geometry,
|
||||
properties: {
|
||||
id: row.featureId,
|
||||
entityId: row.entityId,
|
||||
entityKind: row.entityKind,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
typeName: row.typeName,
|
||||
informationStatus: row.informationStatus,
|
||||
geometryType: row.geometryType,
|
||||
updatedAt: row.updatedAt,
|
||||
href: row.href,
|
||||
contextLine: row.contextLine,
|
||||
departmentName: row.departmentName,
|
||||
areaName: row.areaName,
|
||||
yacimientoName: row.yacimientoName,
|
||||
companyName: row.companyName,
|
||||
sourceGeometries: row.sourceGeometries,
|
||||
},
|
||||
})),
|
||||
meta: { count: rows.length, truncated: false },
|
||||
};
|
||||
}
|
||||
|
||||
async mapDocuments() {
|
||||
const rows = (await this.dataSource.query(`
|
||||
WITH RECURSIVE y_tree AS (
|
||||
SELECT y.id AS yacimiento_id, y.id AS asset_id
|
||||
FROM assets y
|
||||
INNER JOIN asset_types y_type ON y_type.id=y.asset_type_id
|
||||
WHERE lower(y_type.code)='yacimiento'
|
||||
AND y_type.is_active=true
|
||||
AND y.information_status<>'INACTIVE'
|
||||
UNION ALL
|
||||
SELECT tree.yacimiento_id, child.id
|
||||
FROM y_tree tree
|
||||
INNER JOIN assets child ON child.parent_id=tree.asset_id
|
||||
WHERE child.information_status<>'INACTIVE'
|
||||
), y_loc AS (
|
||||
SELECT tree.yacimiento_id,
|
||||
ST_Centroid(ST_Collect(geometry.geometry)) AS geometry,
|
||||
MAX(geometry.updated_at) AS updated_at
|
||||
FROM y_tree tree
|
||||
INNER JOIN asset_geometries geometry ON geometry.asset_id=tree.asset_id
|
||||
GROUP BY tree.yacimiento_id
|
||||
), finding_rows AS (
|
||||
SELECT
|
||||
finding.id,finding.code,finding.title,finding.status,finding.asset_id,
|
||||
act.id AS act_id,act.code AS act_code,act.occurred_at,
|
||||
visit.id AS visit_id,visit.code AS visit_code,
|
||||
target.code AS asset_code,target.name AS asset_name,
|
||||
target_geometry.geometry AS target_geometry,
|
||||
tree.yacimiento_id,
|
||||
y.name AS yacimiento_name,area.name AS area_name,department.name AS department_name,
|
||||
COALESCE(profile.legal_name,company.name) AS company_name,
|
||||
loc.geometry AS fallback_geometry,COALESCE(target_geometry.updated_at,loc.updated_at) AS updated_at
|
||||
FROM inspection_findings finding
|
||||
INNER JOIN inspection_acts act ON act.id=finding.act_id
|
||||
INNER JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||
INNER JOIN assets target ON target.id=finding.asset_id
|
||||
LEFT JOIN asset_geometries target_geometry ON target_geometry.asset_id=target.id
|
||||
LEFT JOIN y_tree tree ON tree.asset_id=target.id
|
||||
LEFT JOIN assets y ON y.id=tree.yacimiento_id
|
||||
LEFT JOIN assets area ON area.id=y.parent_id
|
||||
LEFT JOIN assets department ON department.id=area.parent_id
|
||||
LEFT JOIN assets company ON company.id=y.operator_company_id
|
||||
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
|
||||
LEFT JOIN y_loc loc ON loc.yacimiento_id=tree.yacimiento_id
|
||||
WHERE finding.status<>'VOIDED'
|
||||
), act_yacimiento AS (
|
||||
SELECT act.id AS act_id,
|
||||
COALESCE(
|
||||
CASE WHEN lower(COALESCE(scope_type.code,''))='yacimiento' THEN scope.id END,
|
||||
(SELECT fr.yacimiento_id FROM finding_rows fr WHERE fr.act_id=act.id AND fr.yacimiento_id IS NOT NULL ORDER BY fr.id LIMIT 1)
|
||||
) AS yacimiento_id
|
||||
FROM inspection_acts act
|
||||
INNER JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||
LEFT JOIN assets scope ON scope.id=visit.scope_asset_id
|
||||
LEFT JOIN asset_types scope_type ON scope_type.id=scope.asset_type_id
|
||||
WHERE act.status<>'CANCELLED'
|
||||
)
|
||||
SELECT
|
||||
'FINDING:'||finding.id::text AS "featureId",finding.id AS "entityId",'FINDING'::text AS "entityKind",
|
||||
ST_AsGeoJSON(ST_Centroid(COALESCE(finding.target_geometry,finding.fallback_geometry)))::jsonb AS geometry,
|
||||
finding.code,finding.title AS name,'Hallazgo'::text AS "typeName",finding.status::text AS "informationStatus",
|
||||
'POINT'::text AS "geometryType",finding.updated_at AS "updatedAt",
|
||||
'/hallazgos/'||finding.id::text AS href,
|
||||
concat_ws(' · ',finding.act_code,finding.asset_name,finding.yacimiento_name,finding.company_name) AS "contextLine",
|
||||
finding.department_name AS "departmentName",finding.area_name AS "areaName",
|
||||
finding.yacimiento_name AS "yacimientoName",finding.company_name AS "companyName",
|
||||
finding.act_code AS "actCode",finding.asset_name AS "assetName"
|
||||
FROM finding_rows finding
|
||||
WHERE COALESCE(finding.target_geometry,finding.fallback_geometry) IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
'ACT:'||act.id::text AS "featureId",act.id AS "entityId",'ACT'::text AS "entityKind",
|
||||
ST_AsGeoJSON(loc.geometry)::jsonb AS geometry,
|
||||
act.code,act.title AS name,'Acta'::text AS "typeName",act.status::text AS "informationStatus",
|
||||
'POINT'::text AS "geometryType",COALESCE(loc.updated_at,act.updated_at) AS "updatedAt",
|
||||
'/inspecciones/actas/'||act.id::text AS href,
|
||||
concat_ws(' · ',y.name,area.name,COALESCE(profile.legal_name,company.name)) AS "contextLine",
|
||||
department.name AS "departmentName",area.name AS "areaName",y.name AS "yacimientoName",
|
||||
COALESCE(profile.legal_name,company.name) AS "companyName",act.code AS "actCode",NULL::text AS "assetName"
|
||||
FROM inspection_acts act
|
||||
INNER JOIN act_yacimiento ay ON ay.act_id=act.id
|
||||
INNER JOIN y_loc loc ON loc.yacimiento_id=ay.yacimiento_id
|
||||
INNER JOIN assets y ON y.id=ay.yacimiento_id
|
||||
LEFT JOIN assets area ON area.id=y.parent_id
|
||||
LEFT JOIN assets department ON department.id=area.parent_id
|
||||
LEFT JOIN assets company ON company.id=y.operator_company_id
|
||||
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
|
||||
WHERE act.status<>'CANCELLED'
|
||||
ORDER BY "entityKind","code"
|
||||
`)) as Array<Record<string, unknown>>;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: rows.map((row) => ({
|
||||
type: 'Feature' as const,
|
||||
id: row.featureId,
|
||||
geometry: row.geometry,
|
||||
properties: {
|
||||
id: row.featureId,
|
||||
entityId: row.entityId,
|
||||
entityKind: row.entityKind,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
typeName: row.typeName,
|
||||
informationStatus: row.informationStatus,
|
||||
geometryType: row.geometryType,
|
||||
updatedAt: row.updatedAt,
|
||||
href: row.href,
|
||||
contextLine: row.contextLine,
|
||||
departmentName: row.departmentName,
|
||||
areaName: row.areaName,
|
||||
yacimientoName: row.yacimientoName,
|
||||
companyName: row.companyName,
|
||||
actCode: row.actCode,
|
||||
assetName: row.assetName,
|
||||
},
|
||||
})),
|
||||
meta: { count: rows.length, truncated: false },
|
||||
};
|
||||
}
|
||||
|
||||
private async requireAsset(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
|
||||
@@ -7,6 +7,8 @@ import { AssetsService } from './assets.service';
|
||||
import {
|
||||
AssetGeometriesController,
|
||||
MapAssetsController,
|
||||
MapDocumentsController,
|
||||
MapOperationalContextController,
|
||||
} from './asset-geometries.controller';
|
||||
import { AssetGeometriesService } from './asset-geometries.service';
|
||||
import { AssetHistoryController } from './asset-history.controller';
|
||||
@@ -50,6 +52,8 @@ import { InventoryBrowserService } from './inventory-browser.service';
|
||||
FieldInventoryMergeController,
|
||||
AssetGeometriesController,
|
||||
MapAssetsController,
|
||||
MapOperationalContextController,
|
||||
MapDocumentsController,
|
||||
AssetHistoryController,
|
||||
AssetMediaController,
|
||||
AssetProvenanceController,
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
status: string;
|
||||
pdfStatus: string;
|
||||
wordStatus: string;
|
||||
gedoIfIdentifier: string | null;
|
||||
gedoOfficializedAt: Date | null;
|
||||
generatedAt: Date;
|
||||
}
|
||||
|
||||
interface ActTerritorialContext {
|
||||
department: ActContextAsset | null;
|
||||
area: ActContextAsset | null;
|
||||
yacimiento: ActContextAsset | null;
|
||||
company: ActContextAsset | null;
|
||||
installations: ActContextAsset[];
|
||||
subinstallations: ActContextAsset[];
|
||||
legacyAreaScope: boolean;
|
||||
}
|
||||
|
||||
export interface InspectionActListItem {
|
||||
id: string;
|
||||
visitId: string;
|
||||
@@ -94,6 +107,7 @@ export interface InspectionActListItem {
|
||||
findingCount: number;
|
||||
companies: ActContextAsset[];
|
||||
areas: ActContextAsset[];
|
||||
context: ActTerritorialContext;
|
||||
report: ActReportSummary | null;
|
||||
createdBy: ActPerson | null;
|
||||
updatedBy: ActPerson | null;
|
||||
@@ -547,11 +561,23 @@ export class InspectionActsService {
|
||||
COALESCE(finding_count.total, 0)::integer AS "findingCount",
|
||||
COALESCE(context.companies, '[]'::jsonb) AS companies,
|
||||
COALESCE(context.areas, '[]'::jsonb) AS areas,
|
||||
JSONB_BUILD_OBJECT(
|
||||
'department', context.department,
|
||||
'area', context.area,
|
||||
'yacimiento', context.yacimiento,
|
||||
'company', context.company,
|
||||
'installations', COALESCE(context.installations, '[]'::jsonb),
|
||||
'subinstallations', COALESCE(context.subinstallations, '[]'::jsonb),
|
||||
'legacyAreaScope', context.legacy_area_scope
|
||||
) AS context,
|
||||
CASE WHEN report.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', report.id,
|
||||
'code', report.code,
|
||||
'status', report.status,
|
||||
'pdfStatus', report.pdf_status,
|
||||
'wordStatus', report.word_status,
|
||||
'gedoIfIdentifier', report.gedo_if_identifier,
|
||||
'gedoOfficializedAt', report.gedo_officialized_at,
|
||||
'generatedAt', report.generated_at
|
||||
) END AS report,
|
||||
CASE WHEN creator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
@@ -580,10 +606,64 @@ export class InspectionActsService {
|
||||
) END AS companies,
|
||||
CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
|
||||
JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name)
|
||||
) END AS areas
|
||||
) END AS areas,
|
||||
CASE WHEN department.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',department.id,'code',department.code,'name',department.name
|
||||
) END AS department,
|
||||
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',area.id,'code',area.code,'name',area.name
|
||||
) END AS area,
|
||||
CASE WHEN yacimiento.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',yacimiento.id,'code',yacimiento.code,'name',yacimiento.name
|
||||
) END AS yacimiento,
|
||||
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',company.id,'code',company.code,'name',company.name
|
||||
) END AS company,
|
||||
COALESCE(affected.installations, '[]'::jsonb) AS installations,
|
||||
COALESCE(affected.subinstallations, '[]'::jsonb) AS subinstallations,
|
||||
(lower(COALESCE(scope_type.code,''))='area') AS legacy_area_scope
|
||||
FROM inspection_visits context_visit
|
||||
LEFT JOIN assets company ON company.id=context_visit.operator_company_id
|
||||
LEFT JOIN assets area ON area.id=context_visit.operational_area_id
|
||||
LEFT JOIN assets department ON department.id=area.parent_id
|
||||
LEFT JOIN assets scope_asset ON scope_asset.id=context_visit.scope_asset_id
|
||||
LEFT JOIN asset_types scope_type ON scope_type.id=scope_asset.asset_type_id
|
||||
LEFT JOIN assets yacimiento ON yacimiento.id=CASE
|
||||
WHEN lower(COALESCE(scope_type.code,''))='yacimiento' THEN scope_asset.id
|
||||
ELSE NULL
|
||||
END
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',x.id,'code',x.code,'name',x.name) ORDER BY x.name,x.code)
|
||||
FROM (
|
||||
SELECT DISTINCT installation.id, installation.code, installation.name
|
||||
FROM inspection_findings finding_context
|
||||
INNER JOIN assets finding_asset ON finding_asset.id=finding_context.asset_id
|
||||
INNER JOIN asset_types finding_type ON finding_type.id=finding_asset.asset_type_id
|
||||
LEFT JOIN assets installation ON installation.id=CASE
|
||||
WHEN lower(finding_type.code)='instalacion' THEN finding_asset.id
|
||||
WHEN lower(finding_type.code)='subinstalacion' THEN finding_asset.parent_id
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE finding_context.act_id=act.id
|
||||
AND finding_context.status<>'VOIDED'
|
||||
AND installation.id IS NOT NULL
|
||||
) x
|
||||
), '[]'::jsonb) AS installations,
|
||||
COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',x.id,'code',x.code,'name',x.name) ORDER BY x.name,x.code)
|
||||
FROM (
|
||||
SELECT DISTINCT finding_asset.id, finding_asset.code, finding_asset.name
|
||||
FROM inspection_findings finding_context
|
||||
INNER JOIN assets finding_asset ON finding_asset.id=finding_context.asset_id
|
||||
INNER JOIN asset_types finding_type ON finding_type.id=finding_asset.asset_type_id
|
||||
WHERE finding_context.act_id=act.id
|
||||
AND finding_context.status<>'VOIDED'
|
||||
AND lower(finding_type.code)='subinstalacion'
|
||||
) x
|
||||
), '[]'::jsonb) AS subinstallations
|
||||
) affected ON true
|
||||
WHERE context_visit.id=act.visit_id
|
||||
) context ON true
|
||||
LEFT JOIN LATERAL (
|
||||
|
||||
@@ -41,9 +41,18 @@ interface FindingAssetView {
|
||||
code: string;
|
||||
name: string;
|
||||
commonName: string | null;
|
||||
typeCode: string;
|
||||
typeName: string;
|
||||
operatorCompany: FindingContextView | null;
|
||||
operationalArea: FindingContextView | null;
|
||||
hierarchy: {
|
||||
department: FindingContextView | null;
|
||||
area: FindingContextView | null;
|
||||
yacimiento: FindingContextView | null;
|
||||
installation: FindingContextView | null;
|
||||
subinstallation: FindingContextView | null;
|
||||
company: FindingContextView | null;
|
||||
};
|
||||
}
|
||||
|
||||
type FindingCatalogView = {
|
||||
@@ -809,13 +818,34 @@ export class InspectionFindingsService {
|
||||
'code', asset.code,
|
||||
'name', asset.name,
|
||||
'commonName', asset.common_name,
|
||||
'typeCode', asset_type.code,
|
||||
'typeName', asset_type.name,
|
||||
'operatorCompany', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', company.id, 'code', company.code, 'name', company.name
|
||||
) END,
|
||||
'operationalArea', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', area.id, 'code', area.code, 'name', area.name
|
||||
) END
|
||||
) END,
|
||||
'hierarchy', JSONB_BUILD_OBJECT(
|
||||
'department', CASE WHEN department.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',department.id,'code',department.code,'name',department.name
|
||||
) END,
|
||||
'area', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',area.id,'code',area.code,'name',area.name
|
||||
) END,
|
||||
'yacimiento', CASE WHEN yacimiento.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',yacimiento.id,'code',yacimiento.code,'name',yacimiento.name
|
||||
) END,
|
||||
'installation', CASE WHEN installation.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',installation.id,'code',installation.code,'name',installation.name
|
||||
) END,
|
||||
'subinstallation', CASE WHEN subinstallation.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',subinstallation.id,'code',subinstallation.code,'name',subinstallation.name
|
||||
) END,
|
||||
'company', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',company.id,'code',company.code,'name',company.name
|
||||
) END
|
||||
)
|
||||
) AS asset,
|
||||
CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', catalog.id,
|
||||
@@ -861,6 +891,24 @@ export class InspectionFindingsService {
|
||||
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||
LEFT JOIN assets company ON company.id = asset.operator_company_id
|
||||
LEFT JOIN assets area ON area.id = asset.operational_area_id
|
||||
LEFT JOIN assets department ON department.id = area.parent_id
|
||||
LEFT JOIN assets parent_asset ON parent_asset.id = asset.parent_id
|
||||
LEFT JOIN assets grandparent_asset ON grandparent_asset.id = parent_asset.parent_id
|
||||
LEFT JOIN assets yacimiento ON yacimiento.id = CASE
|
||||
WHEN lower(asset_type.code)='yacimiento' THEN asset.id
|
||||
WHEN lower(asset_type.code)='instalacion' THEN parent_asset.id
|
||||
WHEN lower(asset_type.code)='subinstalacion' THEN grandparent_asset.id
|
||||
ELSE NULL
|
||||
END
|
||||
LEFT JOIN assets installation ON installation.id = CASE
|
||||
WHEN lower(asset_type.code)='instalacion' THEN asset.id
|
||||
WHEN lower(asset_type.code)='subinstalacion' THEN parent_asset.id
|
||||
ELSE NULL
|
||||
END
|
||||
LEFT JOIN assets subinstallation ON subinstallation.id = CASE
|
||||
WHEN lower(asset_type.code)='subinstalacion' THEN asset.id
|
||||
ELSE NULL
|
||||
END
|
||||
LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id
|
||||
LEFT JOIN finding_categories category ON category.id = catalog.category_id
|
||||
LEFT JOIN LATERAL (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
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 type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import {
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
|
||||
import type { OfficializeInspectionReportDto } from './dto/officialize-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;
|
||||
|
||||
@@ -56,6 +59,7 @@ export class InspectionReportWorkflowService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
private readonly administration: ActAdministrationService,
|
||||
config: ConfigService,
|
||||
) {
|
||||
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 }> {
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT
|
||||
@@ -300,6 +340,15 @@ export class InspectionReportWorkflowService {
|
||||
try {
|
||||
return await this.dataSource.transaction(async (manager) => {
|
||||
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);
|
||||
await manager.query(`
|
||||
INSERT INTO inspection_report_follow_ups (
|
||||
@@ -407,6 +456,18 @@ export class InspectionReportWorkflowService {
|
||||
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> {
|
||||
const [row] = await manager.query(`
|
||||
SELECT id,act_id AS "actId",visit_id AS "visitId",code,status,
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
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 type { Response } from 'express';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
@@ -22,6 +24,7 @@ import { CreateInspectionReportFollowUpDto } from './dto/create-inspection-repor
|
||||
import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto';
|
||||
import { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
|
||||
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||
import { SetInspectionReportResponseDeadlineDto } from './dto/set-inspection-report-response-deadline.dto';
|
||||
import {
|
||||
InspectionReportWorkflowService,
|
||||
MAX_INSPECTION_REPORT_FILE_BYTES,
|
||||
@@ -136,6 +139,49 @@ export class InspectionReportsController {
|
||||
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')
|
||||
@RequirePermissions('inspection_reports.read')
|
||||
followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { ActAdministrationModule } from '../act-administration/act-administration.module';
|
||||
import { DocumentDeliveryController } from './document-delivery.controller';
|
||||
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
||||
import { InspectionDeadlineAdminController } from './inspection-deadline-admin.controller';
|
||||
@@ -12,7 +13,7 @@ import { InspectionReportsService } from './inspection-reports.service';
|
||||
import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
imports: [AuditModule, ActAdministrationModule],
|
||||
controllers: [
|
||||
InspectionReportsController,
|
||||
InspectionActReportController,
|
||||
|
||||
@@ -64,6 +64,16 @@ export interface InspectionReportListItem {
|
||||
|
||||
export interface InspectionReportView extends InspectionReportListItem {
|
||||
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 {
|
||||
@@ -491,7 +501,38 @@ export class InspectionReportsService {
|
||||
'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1',
|
||||
[id],
|
||||
)) 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> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { connect as connectNet, Socket } from 'node:net';
|
||||
import { connect as connectTls, TLSSocket } from 'node:tls';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { SmtpSecurityMode } from '../database/entities';
|
||||
@@ -250,9 +250,9 @@ export class SmtpDeliveryService {
|
||||
|
||||
private encryptionKey():Buffer{
|
||||
const raw=this.config.get<string>('SMTP_SETTINGS_MASTER_KEY');
|
||||
if(!raw)throw new Error('SMTP_SETTINGS_MASTER_KEY no configurada');
|
||||
if(!raw)throw new ServiceUnavailableException({ code:'SMTP_SETTINGS_MASTER_KEY_NOT_CONFIGURED', message:'La clave maestra para proteger credenciales SMTP no está configurada en el servidor' });
|
||||
const key=/^[0-9a-fA-F]{64}$/.test(raw)?Buffer.from(raw,'hex'):Buffer.from(raw,'base64');
|
||||
if(key.length!==32)throw new Error('SMTP_SETTINGS_MASTER_KEY debe contener exactamente 32 bytes');
|
||||
if(key.length!==32)throw new ServiceUnavailableException({ code:'SMTP_SETTINGS_MASTER_KEY_INVALID', message:'La clave maestra SMTP del servidor tiene un formato inválido' });
|
||||
return key;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class OpenMobileInspectionDto {
|
||||
@IsUUID('4')
|
||||
departmentId!: string;
|
||||
|
||||
@IsUUID('4')
|
||||
operationalAreaId!: string;
|
||||
|
||||
@IsUUID('4')
|
||||
scopeAssetId!: string;
|
||||
|
||||
@IsUUID('4')
|
||||
operatorCompanyId!: string;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,67 @@ export class InspectionPlanningHierarchyService {
|
||||
return { data };
|
||||
}
|
||||
|
||||
async operatorsForYacimiento(yacimientoId: string): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
|
||||
await this.requireType(yacimientoId, 'yacimiento', 'El Yacimiento seleccionado no es válido');
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT company.id, company.code, COALESCE(profile.legal_name,company.name) AS name
|
||||
FROM assets yacimiento
|
||||
INNER JOIN assets company ON company.id=yacimiento.operator_company_id
|
||||
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
|
||||
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
|
||||
WHERE yacimiento.id=$1::uuid
|
||||
AND yacimiento.information_status<>'INACTIVE'
|
||||
AND company_type.operational_role='COMPANY'
|
||||
AND company_type.is_active=true
|
||||
AND company.information_status<>'INACTIVE'
|
||||
ORDER BY name, company.code
|
||||
`, [yacimientoId]) as InspectionPlanningHierarchyItem[];
|
||||
return { data };
|
||||
}
|
||||
|
||||
async validateMobileSelection(
|
||||
departmentId: string,
|
||||
areaId: string,
|
||||
yacimientoId: string,
|
||||
companyId: string,
|
||||
): Promise<void> {
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT 1
|
||||
FROM assets department
|
||||
INNER JOIN asset_types department_type ON department_type.id=department.asset_type_id
|
||||
INNER JOIN assets area ON area.parent_id=department.id
|
||||
INNER JOIN asset_types area_type ON area_type.id=area.asset_type_id
|
||||
INNER JOIN assets yacimiento ON yacimiento.parent_id=area.id
|
||||
INNER JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
|
||||
INNER JOIN assets company ON company.id=yacimiento.operator_company_id
|
||||
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
|
||||
WHERE department.id=$1::uuid
|
||||
AND area.id=$2::uuid
|
||||
AND yacimiento.id=$3::uuid
|
||||
AND company.id=$4::uuid
|
||||
AND lower(department_type.code)='departamento'
|
||||
AND lower(area_type.code)='area'
|
||||
AND lower(yacimiento_type.code)='yacimiento'
|
||||
AND department_type.is_active=true
|
||||
AND area_type.is_active=true
|
||||
AND yacimiento_type.is_active=true
|
||||
AND company_type.operational_role='COMPANY'
|
||||
AND company_type.is_active=true
|
||||
AND department.information_status<>'INACTIVE'
|
||||
AND area.information_status<>'INACTIVE'
|
||||
AND yacimiento.information_status<>'INACTIVE'
|
||||
AND company.information_status<>'INACTIVE'
|
||||
AND yacimiento.concession_type_id IS NOT NULL
|
||||
LIMIT 1
|
||||
`, [departmentId, areaId, yacimientoId, companyId]) as Array<{ '?column?': number }>;
|
||||
if (!row) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_MOBILE_CONTEXT_INVALID',
|
||||
message: 'La selección debe respetar Departamento → Área → Yacimiento → Operadora',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async operatorsForArea(
|
||||
areaId: string,
|
||||
at?: string,
|
||||
|
||||
@@ -87,21 +87,41 @@ export class InspectionVisitsController {
|
||||
return this.planningHierarchy.operatorsForArea(areaId, at);
|
||||
}
|
||||
|
||||
@Get('mobile/planning-context/areas')
|
||||
@Get('mobile/planning-context/departments')
|
||||
@RequirePermissions('inspections.execute')
|
||||
mobilePlanningAreas(@CurrentAuth() principal: AuthPrincipal) {
|
||||
mobilePlanningDepartments(@CurrentAuth() principal: AuthPrincipal) {
|
||||
assertMobileInspector(principal);
|
||||
return this.visits.listPlanningAreas();
|
||||
return this.planningHierarchy.departments();
|
||||
}
|
||||
|
||||
@Get('mobile/planning-context/areas/:areaId/operators')
|
||||
@Get('mobile/planning-context/departments/:departmentId/areas')
|
||||
@RequirePermissions('inspections.execute')
|
||||
mobilePlanningOperators(
|
||||
mobilePlanningAreasForDepartment(
|
||||
@Param('departmentId', new ParseUUIDPipe({ version: '4' })) departmentId: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
) {
|
||||
assertMobileInspector(principal);
|
||||
return this.planningHierarchy.areasForDepartment(departmentId);
|
||||
}
|
||||
|
||||
@Get('mobile/planning-context/areas/:areaId/yacimientos')
|
||||
@RequirePermissions('inspections.execute')
|
||||
mobilePlanningYacimientos(
|
||||
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
) {
|
||||
assertMobileInspector(principal);
|
||||
return this.visits.listPlanningOperators(areaId);
|
||||
return this.planningHierarchy.yacimientosForArea(areaId);
|
||||
}
|
||||
|
||||
@Get('mobile/planning-context/yacimientos/:yacimientoId/operators')
|
||||
@RequirePermissions('inspections.execute')
|
||||
mobilePlanningOperatorsForYacimiento(
|
||||
@Param('yacimientoId', new ParseUUIDPipe({ version: '4' })) yacimientoId: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
) {
|
||||
assertMobileInspector(principal);
|
||||
return this.planningHierarchy.operatorsForYacimiento(yacimientoId);
|
||||
}
|
||||
|
||||
@Post('mobile/open')
|
||||
@@ -112,10 +132,15 @@ export class InspectionVisitsController {
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
assertMobileInspector(principal);
|
||||
// Android conserva por compatibilidad el alcance a nivel Área hasta que su
|
||||
// flujo también solicite Yacimiento. No se mezcla con la creación WEB F6.1.
|
||||
const created = await this.visits.create({
|
||||
await this.planningHierarchy.validateMobileSelection(
|
||||
dto.departmentId,
|
||||
dto.operationalAreaId,
|
||||
dto.scopeAssetId,
|
||||
dto.operatorCompanyId,
|
||||
);
|
||||
const created = await this.planningCreate.create({
|
||||
operationalAreaId: dto.operationalAreaId,
|
||||
scopeAssetId: dto.scopeAssetId,
|
||||
operatorCompanyId: dto.operatorCompanyId,
|
||||
plannedStartAt: new Date().toISOString(),
|
||||
leadInspectorUserId: principal.userId,
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const API_VERSION = '0.29.0-11';
|
||||
export const API_PHASE = 'F6.10';
|
||||
export const API_VERSION = '0.29.0-17';
|
||||
export const API_PHASE = 'F6.17';
|
||||
|
||||
@@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { API_PHASE, API_VERSION } from '../../src/version';
|
||||
|
||||
test('health metadata reports the current F6.10 release', () => {
|
||||
assert.equal(API_PHASE, 'F6.10');
|
||||
test('health metadata reports the current F6.17 release', () => {
|
||||
assert.equal(API_PHASE, 'F6.17');
|
||||
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
|
||||
assert.equal(API_VERSION, pkg.version);
|
||||
assert.equal(API_VERSION, '0.29.0-11');
|
||||
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', () => {
|
||||
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
|
||||
|
||||
assert.match(gradle, /versionCode = 39/);
|
||||
assert.match(gradle, /versionName = "0\.19\.11"/);
|
||||
assert.match(gradle, /versionCode = 41/);
|
||||
assert.match(gradle, /versionName = "0\.19\.13"/);
|
||||
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
||||
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
|
||||
});
|
||||
|
||||
@@ -36,33 +36,48 @@ test('F6.1 uses contextual type catalog for Yacimiento and keeps OTROS/add-anoth
|
||||
assert.match(androidFinding, /También podés registrar otro Hallazgo sobre el mismo Inventario/);
|
||||
});
|
||||
|
||||
test('F6.1 exposes mobile planning context and opens an inspection self-assigned to the current inspector', () => {
|
||||
test('F6.14 mobile planning requires Departamento → Área → Yacimiento → Operadora before opening', () => {
|
||||
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
|
||||
const hierarchy = source('src/inspection-visits/inspection-planning-hierarchy.service.ts');
|
||||
const dto = source('src/inspection-visits/dto/open-mobile-inspection.dto.ts');
|
||||
|
||||
assert.match(controller, /@Get\('mobile\/planning-context\/areas'\)/);
|
||||
assert.match(controller, /@Get\('mobile\/planning-context\/areas\/:areaId\/operators'\)/);
|
||||
assert.match(controller, /@Get\('mobile\/planning-context\/departments'\)/);
|
||||
assert.match(controller, /@Get\('mobile\/planning-context\/departments\/:departmentId\/areas'\)/);
|
||||
assert.match(controller, /@Get\('mobile\/planning-context\/areas\/:areaId\/yacimientos'\)/);
|
||||
assert.match(controller, /@Get\('mobile\/planning-context\/yacimientos\/:yacimientoId\/operators'\)/);
|
||||
assert.match(controller, /@Post\('mobile\/open'\)/);
|
||||
assert.match(controller, /@RequirePermissions\('inspections\.execute'\)/);
|
||||
assert.match(controller, /assertMobileInspector\(principal\)/);
|
||||
assert.match(controller, /validateMobileSelection/);
|
||||
assert.match(controller, /scopeAssetId: dto\.scopeAssetId/);
|
||||
assert.match(controller, /leadInspectorUserId: principal\.userId/);
|
||||
assert.match(controller, /plannedStartAt: new Date\(\)\.toISOString\(\)/);
|
||||
assert.match(controller, /this\.lifecycle\.plan\(created\.id/);
|
||||
assert.match(controller, /this\.lifecycle\.start\(created\.id/);
|
||||
assert.match(hierarchy, /Departamento → Área → Yacimiento → Operadora/);
|
||||
assert.match(dto, /departmentId!: string/);
|
||||
assert.match(dto, /operationalAreaId!: string/);
|
||||
assert.match(dto, /scopeAssetId!: string/);
|
||||
assert.match(dto, /operatorCompanyId!: string/);
|
||||
});
|
||||
|
||||
test('F6.1 Android lets the inspector choose Area and current Operator and open the inspection now', () => {
|
||||
test('F6.14 Android selects the full territorial chain and enters Actas for an active inspection', () => {
|
||||
const client = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt');
|
||||
const home = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt');
|
||||
const root = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt');
|
||||
const acts = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt');
|
||||
const gate = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt');
|
||||
|
||||
assert.match(client, /inspection-visits\/mobile\/planning-context\/areas/);
|
||||
assert.match(client, /inspection-visits\/mobile\/open/);
|
||||
assert.match(home, /Abrir inspección/);
|
||||
assert.match(home, /Abrir inspección ahora/);
|
||||
assert.match(home, /Operadora vigente/);
|
||||
assert.match(client, /planning-context\/departments/);
|
||||
assert.match(client, /departments\/\{departmentId\}\/areas/);
|
||||
assert.match(client, /areas\/\{areaId\}\/yacimientos/);
|
||||
assert.match(client, /yacimientos\/\{yacimientoId\}\/operators/);
|
||||
assert.match(client, /OpenMobileInspectionRequest\(departmentId, areaId, yacimientoId, companyId\)/);
|
||||
assert.match(home, /1\. Departamento/);
|
||||
assert.match(home, /2\. Área/);
|
||||
assert.match(home, /3\. Yacimiento/);
|
||||
assert.match(home, /4\. Operadora/);
|
||||
assert.match(home, /Abrir inspección y continuar al Acta/);
|
||||
assert.match(home, /model\.openVisit\(opened\.id\)/);
|
||||
assert.match(root, /if \(visit\.status == "IN_PROGRESS"\) ModernVisitScreen\.ACTS/);
|
||||
assert.match(root, /Buscar Yacimiento, Instalación o Subinstalación/);
|
||||
assert.match(acts, /Elegí el Yacimiento, una Instalación o una Subinstalación/);
|
||||
assert.match(gate, /else -> MobileHomeScreen\(model\)/);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack
|
||||
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
|
||||
|
||||
assert.equal(visibleVersion, pkg.version);
|
||||
assert.match(version, /APP_PHASE\s*=\s*'F6\.9/);
|
||||
assert.match(version, /APP_PHASE\s*=\s*'F6\.17/);
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
@@ -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.
|
||||
@@ -4,6 +4,7 @@ COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY tsconfig*.json vite.config.ts index.html ./
|
||||
COPY public ./public
|
||||
COPY scripts ./scripts
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location = /assets/maplibre-gl-worker.mjs {
|
||||
default_type application/javascript;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "dhv2-web",
|
||||
"version": "0.23.0-7",
|
||||
"version": "0.23.0-13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dhv2-web",
|
||||
"version": "0.23.0-7",
|
||||
"version": "0.23.0-13",
|
||||
"dependencies": {
|
||||
"maplibre-gl": "6.4.1",
|
||||
"react": "^19.0.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-web",
|
||||
"version": "0.23.0-7",
|
||||
"version": "0.23.0-13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "tsc -b && vite build && node scripts/copy-maplibre-worker.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { chmodSync, copyFileSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const source = resolve('node_modules/maplibre-gl/dist/maplibre-gl-worker.mjs');
|
||||
const target = resolve('dist/assets/maplibre-gl-worker.mjs');
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
copyFileSync(source, target);
|
||||
chmodSync(target, 0o644);
|
||||
const bytes = statSync(target).size;
|
||||
if (bytes < 1000) throw new Error(`MapLibre worker inválido: ${bytes} bytes`);
|
||||
console.log(`MapLibre worker: ${target} (${bytes} bytes)`);
|
||||
@@ -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-7';
|
||||
export const APP_PHASE = 'F6.9 · Actas e informes consolidados';
|
||||
export const APP_VERSION = '0.23.0-13';
|
||||
export const APP_PHASE = 'F6.17 · Tablas ordenables';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../../components/SortableTable';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useAuth } from '../../auth/AuthContext';
|
||||
@@ -225,7 +226,7 @@ export function AssetDossierPanel({ assetId }: { assetId: string }) {
|
||||
|
||||
<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>
|
||||
{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>
|
||||
</div>}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../../components/SortableTable';
|
||||
import { SearchableSelect } from '../../components/SearchableSelect';
|
||||
import { useEffect, useMemo, useState } 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>
|
||||
</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;
|
||||
return <tr key={relation.id}>
|
||||
<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>
|
||||
: <button type="button" className="button danger-outline compact" onClick={() => { setEndingId(relation.id); setEndReason(''); }}>Finalizar</button>)}</td>
|
||||
</tr>;
|
||||
})}</tbody></table></div>}
|
||||
})}</tbody></SortableTable></div>}
|
||||
</article>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../../components/SortableTable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
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>
|
||||
<span className={`status-badge ${stateClass(act.adminState)}`}>{stateLabel(act.adminState)}</span>
|
||||
</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>
|
||||
<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>
|
||||
@@ -85,7 +86,7 @@ export function FieldBriefingPanel({ visit }: { visit: InspectionVisit }) {
|
||||
<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>
|
||||
</tr>)}</tbody>
|
||||
</table></div>
|
||||
</SortableTable></div>
|
||||
<div className="table-summary">
|
||||
<span>Plazo empresa: <strong>{act.responseDueOn ? formatDateOnly(act.responseDueOn) : 'sin definir'}</strong></span>
|
||||
<span>Respuesta: <strong>{act.responseReceivedOn ? formatDateOnly(act.responseReceivedOn) : 'pendiente'}</strong></span>
|
||||
|
||||
@@ -30,9 +30,18 @@ function Photo({ id, title, caption, load }: { id: string; title: string; captio
|
||||
|
||||
function Finding({ item }: { item: FindingWithPhotos }) {
|
||||
const { finding, photos } = item;
|
||||
const hierarchy = finding.asset.hierarchy;
|
||||
return <article className="act-finding-record">
|
||||
<div className="inspection-finding-heading"><div><span className="eyebrow">{finding.code}</span><h3>{finding.title}</h3><p>{finding.asset.name} · {finding.asset.code}</p></div></div>
|
||||
<p className="inspection-finding-description">{finding.description}</p>
|
||||
<div className="inspection-finding-heading"><div><span className="eyebrow">{finding.code}</span><h3>{finding.title}</h3><p><strong>Elemento afectado:</strong> {finding.asset.typeName} · {finding.asset.name} · {finding.asset.code}</p></div></div>
|
||||
{hierarchy && <div className="responsible-summary act-finding-context">
|
||||
<div><small>Departamento</small><strong>{hierarchy.department?.name ?? '—'}</strong></div>
|
||||
<div><small>Área</small><strong>{hierarchy.area?.name ?? '—'}</strong></div>
|
||||
<div><small>Yacimiento</small><strong>{hierarchy.yacimiento?.name ?? '—'}</strong></div>
|
||||
<div><small>Empresa</small><strong>{hierarchy.company?.name ?? '—'}</strong></div>
|
||||
<div><small>Instalación</small><strong>{hierarchy.installation?.name ?? 'No corresponde'}</strong></div>
|
||||
<div><small>Subinstalación</small><strong>{hierarchy.subinstallation?.name ?? 'No corresponde'}</strong></div>
|
||||
</div>}
|
||||
<p className="inspection-finding-description"><strong>Constatación:</strong> {finding.description}</p>
|
||||
{finding.legalBasis && <p><strong>Normativa:</strong> {finding.legalBasis}</p>}
|
||||
{finding.severity != null && <p>Gravedad {finding.severity}/10</p>}
|
||||
{photos.length > 0 && <div className="act-finding-photos">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../../components/SortableTable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useAuth } from '../../auth/AuthContext';
|
||||
@@ -37,7 +38,7 @@ export function InspectionActsPanel({ visit }: { visit: InspectionVisit }) {
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{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.'} />
|
||||
: <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>}
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -41,22 +41,22 @@ function localDateTime(value: string | number | Date): string {
|
||||
|
||||
function geometryVertices(geometry: GeoJsonGeometry | null): Position[] {
|
||||
if (!geometry) return [];
|
||||
if (geometry.type === 'POINT') return [geometry.coordinates];
|
||||
if (geometry.type === 'LINESTRING') return geometry.coordinates;
|
||||
if (geometry.type === 'Point') return [geometry.coordinates];
|
||||
if (geometry.type === 'LineString') return geometry.coordinates;
|
||||
return geometry.coordinates[0]?.slice(0, -1) ?? [];
|
||||
}
|
||||
|
||||
function draftGeometry(type: AssetGeometryType, vertices: Position[]): GeoJsonGeometry | null {
|
||||
if (type === 'POINT') return vertices[0] ? { type, coordinates: vertices[0] } : null;
|
||||
if (type === 'LINESTRING') return vertices.length >= 2 ? { type, coordinates: vertices } : null;
|
||||
if (type === 'POINT') return vertices[0] ? { type: 'Point', coordinates: vertices[0] } : null;
|
||||
if (type === 'LINESTRING') return vertices.length >= 2 ? { type: 'LineString', coordinates: vertices } : null;
|
||||
if (vertices.length < 3) return null;
|
||||
return { type, coordinates: [[...vertices, vertices[0]!]] };
|
||||
return { type: 'Polygon', coordinates: [[...vertices, vertices[0]!]] };
|
||||
}
|
||||
|
||||
function drawingCollection(geometry: GeoJsonGeometry | null, vertices: Position[]) {
|
||||
const features: unknown[] = [];
|
||||
if (geometry) features.push({ type: 'Feature', properties: { kind: 'shape' }, geometry });
|
||||
if (geometry?.type !== 'POINT') {
|
||||
if (geometry?.type !== 'Point') {
|
||||
vertices.forEach((coordinates, index) => features.push({
|
||||
type: 'Feature', properties: { kind: 'vertex', index: index + 1 },
|
||||
geometry: { type: 'Point', coordinates },
|
||||
@@ -187,7 +187,7 @@ export function AssetGeometryEditor({
|
||||
const applyStored = (value: AssetGeometry | null) => {
|
||||
setStored(value);
|
||||
if (value) {
|
||||
setType(value.geometry.type);
|
||||
setType(value.geometryType);
|
||||
setVertices(geometryVertices(value.geometry));
|
||||
setAccuracyM(value.accuracyM == null ? '' : String(value.accuracyM));
|
||||
setCapturedAt(value.capturedAt ? localDateTime(value.capturedAt) : '');
|
||||
|
||||
@@ -1,134 +1,80 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Map,
|
||||
NavigationControl,
|
||||
type GeoJSONSource,
|
||||
type MapGeoJSONFeature,
|
||||
} from 'maplibre-gl';
|
||||
import type { MapAssetFeatureCollection } from '../../lib/api';
|
||||
import { Map as MlMap, Marker, NavigationControl } from 'maplibre-gl';
|
||||
import type { MapAssetFeature, MapAssetFeatureCollection, MapEntityKind } from '../../lib/api';
|
||||
|
||||
const osmStyle = {
|
||||
version: 8 as const,
|
||||
sources: {
|
||||
osm: {
|
||||
type: 'raster' as const,
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
},
|
||||
},
|
||||
sources: { 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' }],
|
||||
};
|
||||
|
||||
const interactiveLayers = ['assets-points', 'assets-lines', 'assets-polygons'];
|
||||
type GpsPoint = { feature: MapAssetFeature; coordinates: [number, number] };
|
||||
|
||||
function boundsFromFeatures(collection: MapAssetFeatureCollection) {
|
||||
const positions: Array<[number, number]> = [];
|
||||
collection.features.forEach((feature) => {
|
||||
if (feature.geometry.type === 'POINT') positions.push(feature.geometry.coordinates);
|
||||
if (feature.geometry.type === 'LINESTRING') positions.push(...feature.geometry.coordinates);
|
||||
if (feature.geometry.type === 'POLYGON') feature.geometry.coordinates.forEach((ring) => positions.push(...ring));
|
||||
});
|
||||
if (!positions.length) return null;
|
||||
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]]);
|
||||
function pointCoordinates(feature: MapAssetFeature): [number, number] | null {
|
||||
const geometry = feature.geometry as { type: string; coordinates: unknown };
|
||||
if (geometry.type.toLowerCase() !== 'point' || !Array.isArray(geometry.coordinates)) return null;
|
||||
const [longitude, latitude] = geometry.coordinates;
|
||||
if (typeof longitude !== 'number' || typeof latitude !== 'number') return null;
|
||||
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return null;
|
||||
if (longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) return null;
|
||||
return [longitude, latitude];
|
||||
}
|
||||
|
||||
export function DhMap({
|
||||
data,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
function gpsPoints(collection: MapAssetFeatureCollection): GpsPoint[] {
|
||||
return collection.features.flatMap((feature) => {
|
||||
const coordinates = pointCoordinates(feature);
|
||||
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;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
}) {
|
||||
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 dataRef = useRef(data);
|
||||
onSelectRef.current = onSelect;
|
||||
dataRef.current = data;
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const map = new Map({
|
||||
container: containerRef.current,
|
||||
style: osmStyle,
|
||||
center: [-68.8458, -32.8895],
|
||||
zoom: 6,
|
||||
});
|
||||
const map = new MlMap({ container: containerRef.current, style: osmStyle, center: [-68.8458, -32.8895], zoom: 6 });
|
||||
mapRef.current = map;
|
||||
map.addControl(new NavigationControl(), 'top-right');
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
map.on('click', () => onSelectRef.current(null));
|
||||
return () => {
|
||||
markersRef.current.forEach((marker) => marker.remove());
|
||||
markersRef.current = [];
|
||||
mapRef.current = null;
|
||||
map.remove();
|
||||
};
|
||||
@@ -136,31 +82,24 @@ export function DhMap({
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map?.isStyleLoaded()) return;
|
||||
(map.getSource('assets') as GeoJSONSource | undefined)?.setData(data as never);
|
||||
const bounds = boundsFromFeatures(data);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map?.isStyleLoaded()) return;
|
||||
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]]);
|
||||
if (!map) return;
|
||||
markersRef.current.forEach((marker) => marker.remove());
|
||||
markersRef.current = [];
|
||||
const points = gpsPoints(data);
|
||||
const offsets = visualOffsets(points);
|
||||
points.forEach(({ feature, coordinates }) => {
|
||||
const kind = (feature.properties.entityKind ?? 'ASSET') as MapEntityKind;
|
||||
const element = document.createElement('button');
|
||||
element.type = 'button';
|
||||
element.className = `map-gps-marker kind-${kind.toLowerCase()}${String(feature.id) === selectedId ? ' selected' : ''}`;
|
||||
element.title = `${feature.properties.typeName}: ${feature.properties.name}`;
|
||||
element.setAttribute('aria-label', element.title);
|
||||
element.addEventListener('click', (event) => { event.stopPropagation(); onSelectRef.current(String(feature.id)); });
|
||||
const marker = new Marker({ element, offset: offsets.get(String(feature.id)) ?? [0, 0] }).setLngLat(coordinates).addTo(map);
|
||||
markersRef.current.push(marker);
|
||||
});
|
||||
}, [selectedId]);
|
||||
fitToPoints(map, points);
|
||||
}, [data, selectedId]);
|
||||
|
||||
return <div ref={containerRef} className="map-canvas" />;
|
||||
}
|
||||
|
||||
+52
-12
@@ -536,9 +536,9 @@ export type Position = [number, number];
|
||||
export type AssetGeometryType = 'POINT' | 'LINESTRING' | 'POLYGON';
|
||||
|
||||
export type GeoJsonGeometry =
|
||||
| { type: 'POINT'; coordinates: Position }
|
||||
| { type: 'LINESTRING'; coordinates: Position[] }
|
||||
| { type: 'POLYGON'; coordinates: Position[][] };
|
||||
| { type: 'Point'; coordinates: Position }
|
||||
| { type: 'LineString'; coordinates: Position[] }
|
||||
| { type: 'Polygon'; coordinates: Position[][] };
|
||||
|
||||
export interface AssetGeometry {
|
||||
assetId: string;
|
||||
@@ -553,21 +553,34 @@ export interface AssetGeometry {
|
||||
updatedBy: string | null;
|
||||
}
|
||||
|
||||
export type MapEntityKind = 'ASSET' | 'YACIMIENTO' | 'COMPANY' | 'ACT' | 'FINDING';
|
||||
|
||||
export interface MapAssetProperties {
|
||||
id: string;
|
||||
entityId?: string;
|
||||
entityKind?: MapEntityKind;
|
||||
code: string;
|
||||
name: string;
|
||||
commonName?: string | null;
|
||||
typeId: string;
|
||||
typeCode: string;
|
||||
typeId?: string | null;
|
||||
typeCode?: string | null;
|
||||
typeName: string;
|
||||
parentId: string | null;
|
||||
parentName: string | null;
|
||||
informationStatus: AssetInformationStatus;
|
||||
parentId?: string | null;
|
||||
parentName?: string | null;
|
||||
informationStatus?: AssetInformationStatus | null;
|
||||
geometryType: AssetGeometryType;
|
||||
accuracyM: number | null;
|
||||
capturedAt: string | null;
|
||||
accuracyM?: number | null;
|
||||
capturedAt?: string | null;
|
||||
updatedAt: string;
|
||||
href?: string | null;
|
||||
contextLine?: string | null;
|
||||
departmentName?: string | null;
|
||||
areaName?: string | null;
|
||||
yacimientoName?: string | null;
|
||||
companyName?: string | null;
|
||||
actCode?: string | null;
|
||||
assetName?: string | null;
|
||||
sourceGeometries?: number | null;
|
||||
}
|
||||
|
||||
export interface MapAssetFeature {
|
||||
@@ -1473,7 +1486,17 @@ export interface InspectionFinding {
|
||||
currentVersion: number;
|
||||
closedAt: string | null;
|
||||
closureNotes: string | null;
|
||||
asset: InspectionAssetSummary;
|
||||
asset: InspectionAssetSummary & {
|
||||
typeCode?: string;
|
||||
hierarchy?: {
|
||||
department: { id: string; code: string; name: string } | null;
|
||||
area: { id: string; code: string; name: string } | null;
|
||||
yacimiento: { id: string; code: string; name: string } | null;
|
||||
installation: { id: string; code: string; name: string } | null;
|
||||
subinstallation: { id: string; code: string; name: string } | null;
|
||||
company: { id: string; code: string; name: string } | null;
|
||||
};
|
||||
};
|
||||
catalog: {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -2162,6 +2185,15 @@ export async function getAssetGeometry(assetId: string) {
|
||||
return (await apiRequest<{ data: AssetGeometry | null }>(`/assets/${assetId}/geometry`)).data;
|
||||
}
|
||||
|
||||
function geometryPayload(geometry: GeoJsonGeometry) {
|
||||
const type: AssetGeometryType = geometry.type === 'Point'
|
||||
? 'POINT'
|
||||
: geometry.type === 'LineString'
|
||||
? 'LINESTRING'
|
||||
: 'POLYGON';
|
||||
return { type, coordinates: geometry.coordinates };
|
||||
}
|
||||
|
||||
export function upsertAssetGeometry(assetId: string, input: {
|
||||
geometry: GeoJsonGeometry;
|
||||
accuracyM?: number | null;
|
||||
@@ -2169,7 +2201,7 @@ export function upsertAssetGeometry(assetId: string, input: {
|
||||
deviceLabel?: string | null;
|
||||
}) {
|
||||
return apiRequest<AssetGeometry>(`/assets/${assetId}/geometry`, {
|
||||
method: 'PUT', body: JSON.stringify(input),
|
||||
method: 'PUT', body: JSON.stringify({ ...input, geometry: geometryPayload(input.geometry) }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2179,6 +2211,14 @@ export function removeAssetGeometry(assetId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function getMapOperationalContext() {
|
||||
return apiRequest<MapAssetFeatureCollection>('/map/context');
|
||||
}
|
||||
|
||||
export function getMapDocuments() {
|
||||
return apiRequest<MapAssetFeatureCollection>('/map/documents');
|
||||
}
|
||||
|
||||
export function getMapAssets(params: {
|
||||
bbox?: string;
|
||||
typeId?: string;
|
||||
|
||||
@@ -73,11 +73,23 @@ export interface InspectionActListItemF4 {
|
||||
findingCount: number;
|
||||
companies: Array<{ id: string; code: string; name: string }>;
|
||||
areas: Array<{ id: string; code: string; name: string }>;
|
||||
context: {
|
||||
department: { id: string; code: string; name: string } | null;
|
||||
area: { id: string; code: string; name: string } | null;
|
||||
yacimiento: { id: string; code: string; name: string } | null;
|
||||
company: { id: string; code: string; name: string } | null;
|
||||
installations: Array<{ id: string; code: string; name: string }>;
|
||||
subinstallations: Array<{ id: string; code: string; name: string }>;
|
||||
legacyAreaScope: boolean;
|
||||
};
|
||||
report: null | {
|
||||
id: string;
|
||||
code: string;
|
||||
status: InspectionReportStatusF4;
|
||||
pdfStatus: InspectionReportPdfStatus;
|
||||
wordStatus: 'PENDING' | 'READY' | 'FAILED';
|
||||
gedoIfIdentifier: string | null;
|
||||
gedoOfficializedAt: string | null;
|
||||
generatedAt: string;
|
||||
};
|
||||
createdBy: InspectionPerson | null;
|
||||
|
||||
@@ -64,6 +64,14 @@ export interface InspectionReportDetailF4 {
|
||||
companies: Array<{ id: string; code: string; name: string }>;
|
||||
areas: Array<{ id: string; code: string; name: string }>;
|
||||
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 {
|
||||
@@ -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(
|
||||
id: string,
|
||||
input: {
|
||||
@@ -206,6 +238,10 @@ export function inspectionReportGedoPdfDownloadUrl(id: string) {
|
||||
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`;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../components/SortableTable';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
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]);
|
||||
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>
|
||||
{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 { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
@@ -111,15 +112,15 @@ export function ActsPage() {
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label="Cargando actas…" /> : items.length === 0 ? <EmptyState title="Sin actas" text="No hay actas para los filtros seleccionados." /> : <div className="table-panel document-table">
|
||||
<div className="table-summary"><strong>{meta.total} acta{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div>
|
||||
<div className="table-scroll"><table><thead><tr><th>Acta</th><th>Empresa / área</th><th>Inspección</th><th>Hallazgos</th><th>Estado</th><th>Informe</th><th /></tr></thead><tbody>{items.map((act) => <tr key={act.id}>
|
||||
<div className="table-scroll"><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>{formatDate(act.occurredAt)}</small></div></td>
|
||||
<td><div className="document-primary"><strong>{contextLabel(act.companies, 'Empresa sin asignar')}</strong><small>{contextLabel(act.areas, 'Área sin asignar')}</small></div></td>
|
||||
<td><div className="document-primary"><strong>{act.context.company?.name ?? contextLabel(act.companies, 'Empresa sin asignar')}</strong><small>{[act.context.department?.name, act.context.area?.name, act.context.yacimiento?.name].filter(Boolean).join(' · ') || contextLabel(act.areas, 'Área sin asignar')}</small></div></td>
|
||||
<td><Link className="text-link" to={`/inspecciones/${act.visitId}`}>{act.visit.code}</Link></td>
|
||||
<td><Link className="text-link" to={`/hallazgos?search=${encodeURIComponent(act.code)}`}>{act.findingCount}</Link></td>
|
||||
<td><span className={`status-badge ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span></td>
|
||||
<td>{act.report ? <Link className="text-link" to={`/informes/${act.report.id}`}>{act.report.code}<small className="block-muted">{act.report.pdfStatus === 'READY' ? 'PDF disponible' : 'PDF pendiente'}</small></Link> : ['SEALED', 'CLOSED'].includes(act.status) ? <span className="status-badge pending">Pendiente de emisión</span> : <span className="muted">—</span>}</td>
|
||||
<td>{act.report ? <Link className="text-link" to={`/informes/${act.report.id}`}>{act.report.code}<small className="block-muted">{act.report.status === 'OFFICIALIZED' ? `Oficializado en GEDO${act.report.gedoIfIdentifier ? ` · ${act.report.gedoIfIdentifier}` : ''}` : act.report.wordStatus === 'READY' ? 'INF listo' : 'En preparación'}</small></Link> : ['SEALED', 'CLOSED'].includes(act.status) ? <span className="status-badge pending">Pendiente de emisión</span> : <span className="muted">—</span>}</td>
|
||||
<td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><Icon name="chevron" /></Link></td>
|
||||
</tr>)}</tbody></table></div>
|
||||
</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>}
|
||||
</section>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../components/SortableTable';
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
@@ -654,9 +655,9 @@ Escribí IMPORTAR para continuar:`);
|
||||
{PLAN_GROUPS.map((group) => {
|
||||
const visibleKinds = group.kinds.filter((kind) => Number(record(planByKind[kind])?.total ?? 0) > 0);
|
||||
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>}
|
||||
|
||||
{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-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' ? <>
|
||||
<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}>
|
||||
@@ -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>
|
||||
</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>
|
||||
</>}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../components/SortableTable';
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useMemo, useState } 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.'} />
|
||||
: <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-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}>
|
||||
<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>
|
||||
@@ -194,7 +195,7 @@ export function AssetsPage() {
|
||||
<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>
|
||||
</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>}
|
||||
</section>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../components/SortableTable';
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useState } 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>
|
||||
</form>
|
||||
{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>}
|
||||
</section>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../components/SortableTable';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
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>
|
||||
<label className="company-document-search"><Icon name="search" /><input value={documentSearch} onChange={(event) => setDocumentSearch(event.target.value)} placeholder="Buscar por acta, informe, inspección, área o GEDO" /></label>
|
||||
{documentsError && <Alert>{documentsError}</Alert>}
|
||||
{documentsLoading ? <LoadingBlock label="Cargando documentación emitida…" /> : filteredActs.length === 0 ? <EmptyState title="Sin documentación emitida" text="Todavía no hay Actas selladas para esta empresa." /> : <div className="table-scroll company-doc-table"><table><thead><tr><th>Fecha</th><th>Área</th><th>Acta</th><th>Inspección</th><th>Informe</th><th>Oficialización</th><th /></tr></thead><tbody>{filteredActs.map((act) => {
|
||||
{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);
|
||||
return <tr key={act.id}>
|
||||
<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 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>}
|
||||
})}</tbody></SortableTable></div>}
|
||||
</article>
|
||||
</div>}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../components/SortableTable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
@@ -189,7 +190,7 @@ export function DocumentDeliveryPage() {
|
||||
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD</span><h2>Últimas entregas</h2><p className="section-copy">Actas PDF a Empresa/Oficina/Inspector e INF Word editable al Inspector responsable.</p></div><span className="count-pill">{items.length}</span></div>
|
||||
{items.length === 0 ? <div className="inline-empty">Todavía no existen entregas documentales.</div> : <div className="table-wrap"><table><thead><tr><th>Documento</th><th>Destino</th><th>Estado</th><th>Intentos</th><th /></tr></thead><tbody>{items.map((item) => <tr key={item.id}><td><strong>{item.documentKind === 'ACT_PDF' ? item.actCode : item.reportCode}</strong><small className="block-muted">{item.documentKind === 'ACT_PDF' ? 'Acta PDF inmutable' : 'INF Word editable'}</small></td><td>{recipientLabel(item)}<small className="block-muted">{item.recipientEmail ?? 'Sin email configurado'}</small></td><td><span className={`status-badge ${item.status === 'SENT' ? 'active' : item.status === 'FAILED' ? 'inactive' : 'pending'}`}>{statusLabel[item.status]}</span>{item.lastError && <small className="block-muted">{item.lastError}</small>}</td><td>{item.attempts}</td><td>{canManage && item.status !== 'SENT' && <button className="button text" type="button" disabled={saving} onClick={async () => { setSaving(true); try { await retryDocumentDeliveryF4(item.id); await load(); } catch (requestError) { setError(errorMessage(requestError)); } finally { setSaving(false); } }}>Reintentar</button>}</td></tr>)}</tbody></table></div>}
|
||||
{items.length === 0 ? <div className="inline-empty">Todavía no existen entregas documentales.</div> : <div className="table-wrap"><SortableTable><thead><tr><th>Documento</th><th>Destino</th><th>Estado</th><th>Intentos</th><th /></tr></thead><tbody>{items.map((item) => <tr key={item.id}><td><strong>{item.documentKind === 'ACT_PDF' ? item.actCode : item.reportCode}</strong><small className="block-muted">{item.documentKind === 'ACT_PDF' ? 'Acta PDF inmutable' : 'INF Word editable'}</small></td><td>{recipientLabel(item)}<small className="block-muted">{item.recipientEmail ?? 'Sin email configurado'}</small></td><td><span className={`status-badge ${item.status === 'SENT' ? 'active' : item.status === 'FAILED' ? 'inactive' : 'pending'}`}>{statusLabel[item.status]}</span>{item.lastError && <small className="block-muted">{item.lastError}</small>}</td><td>{item.attempts}</td><td>{canManage && item.status !== 'SENT' && <button className="button text" type="button" disabled={saving} onClick={async () => { setSaving(true); try { await retryDocumentDeliveryF4(item.id); await load(); } catch (requestError) { setError(errorMessage(requestError)); } finally { setSaving(false); } }}>Reintentar</button>}</td></tr>)}</tbody></SortableTable></div>}
|
||||
</article>
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../components/SortableTable';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
@@ -244,7 +245,7 @@ export function FieldBriefingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<SortableTable>
|
||||
<thead><tr><th>Qué hacer</th><th>Hallazgo</th><th>Inventario</th><th>Control</th></tr></thead>
|
||||
<tbody>{act.findings.map((finding) => {
|
||||
const action = findingAction(finding, act, briefing.plannedOn);
|
||||
@@ -264,7 +265,7 @@ export function FieldBriefingsPage() {
|
||||
<td>{finding.nextControlOn ? formatDateOnly(finding.nextControlOn) : 'Sin fecha'}</td>
|
||||
</tr>;
|
||||
})}</tbody>
|
||||
</table>
|
||||
</SortableTable>
|
||||
</div>
|
||||
</article>)}
|
||||
</>}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../components/SortableTable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
@@ -126,7 +127,7 @@ export function FieldDiscoveriesPage() {
|
||||
|
||||
{loading ? <LoadingBlock label="Cargando altas de campo…" /> : items.length === 0 ? <EmptyState title="No hay altas en esta bandeja" text="Cuando un inspector registre un elemento nuevo durante una visita aparecerá aquí." /> : <div className="table-panel">
|
||||
<div className="table-summary"><strong>{meta.total} alta{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>Elemento</th><th>Inspección</th><th>Inspector</th><th>Estado</th><th>Acciones</th></tr></thead><tbody>
|
||||
<div className="table-scroll"><SortableTable><thead><tr><th>Elemento</th><th>Inspección</th><th>Inspector</th><th>Estado</th><th>Acciones</th></tr></thead><tbody>
|
||||
{items.map((item) => <tr key={item.id}>
|
||||
<td><Link className="table-primary" to={`/inventarios/${item.asset.id}`}>{item.asset.name}</Link><small className="cell-subtext">{item.asset.code} · {item.asset.typeName}{item.asset.commonName ? ` · ${item.asset.commonName}` : ''}</small></td>
|
||||
<td><Link to={`/inspecciones/${item.visit.id}`}>{item.visit.code}</Link><small className="cell-subtext">{formatDate(item.observedAt)}</small></td>
|
||||
@@ -141,14 +142,14 @@ export function FieldDiscoveriesPage() {
|
||||
</>}
|
||||
</td>
|
||||
</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>}
|
||||
|
||||
{matchFor && <div className="panel field-discovery-match-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">CONCILIAR</span><h2>Buscar registro existente</h2><p className="section-copy">Buscá dentro de la misma Área física. El alta de campo se archivará, pero su historial no se elimina.</p></div><button className="button text" onClick={() => setMatchFor(null)}>Cerrar</button></div>
|
||||
<form className="asset-center-search" onSubmit={searchMatches}><Icon name="search" /><input value={matchSearch} onChange={(event) => setMatchSearch(event.target.value)} placeholder="Nombre, sobrenombre o código existente…" /><button className="button primary" disabled={matchLoading}>{matchLoading ? 'Buscando…' : 'Buscar'}</button></form>
|
||||
{candidates.length > 0 && <div className="table-scroll"><table><thead><tr><th>Registro existente</th><th>Tipo</th><th /></tr></thead><tbody>{candidates.map((candidate) => <tr key={candidate.id}><td><strong>{candidate.name}</strong><small className="cell-subtext">{candidate.code}{candidate.commonName ? ` · ${candidate.commonName}` : ''}</small></td><td>{candidate.type.name}</td><td className="action-cell"><button className="button primary compact" onClick={() => chooseMatch(candidate)}>Usar esta coincidencia</button></td></tr>)}</tbody></table></div>}
|
||||
{candidates.length > 0 && <div className="table-scroll"><SortableTable><thead><tr><th>Registro existente</th><th>Tipo</th><th /></tr></thead><tbody>{candidates.map((candidate) => <tr key={candidate.id}><td><strong>{candidate.name}</strong><small className="cell-subtext">{candidate.code}{candidate.commonName ? ` · ${candidate.commonName}` : ''}</small></td><td>{candidate.type.name}</td><td className="action-cell"><button className="button primary compact" onClick={() => chooseMatch(candidate)}>Usar esta coincidencia</button></td></tr>)}</tbody></SortableTable></div>}
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../components/SortableTable';
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
@@ -160,7 +161,7 @@ export function FindingsPage() {
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label="Cargando hallazgos…" /> : items.length === 0 ? <EmptyState title="Sin hallazgos en esta vista" text="No hay tareas técnicas pendientes para el filtro seleccionado." /> : <div className="table-panel findings-worklist">
|
||||
<div className="table-summary"><strong>{meta.total} hallazgo{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>Hallazgo</th><th>Empresa / área</th><th>Elemento</th><th>Situación</th><th>Fecha de control</th><th>Descripción</th><th /></tr></thead><tbody>{items.map((finding) => {
|
||||
<div className="table-scroll"><SortableTable><thead><tr><th>Hallazgo</th><th>Empresa / área</th><th>Elemento</th><th>Situación</th><th>Fecha de control</th><th>Descripción</th><th /></tr></thead><tbody>{items.map((finding) => {
|
||||
const attention = attentionLabel(finding);
|
||||
const deadline = deadlineText(finding);
|
||||
return <tr key={finding.id}>
|
||||
@@ -172,7 +173,7 @@ export function FindingsPage() {
|
||||
<td><small>{finding.description}</small></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>
|
||||
})}</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>}
|
||||
</section>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../components/SortableTable';
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
@@ -123,7 +124,7 @@ export function HistoryPage() {
|
||||
</form>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label="Consultando versiones…" /> : versions.length === 0 ? <EmptyState title="Sin versiones" text="No hay versiones históricas para los filtros seleccionados." /> : <div className="table-panel history-table"><div className="table-summary"><strong>{meta.total} versiones registradas</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><table><thead><tr><th>Fecha y hora</th><th>Registro</th><th>Versión</th><th>Cambio</th><th>Campos</th><th>Usuario</th><th>Estado</th><th /></tr></thead><tbody>{versions.map((version) => <tr key={version.id}><td>{formatDate(version.occurredAt)}</td><td><Link className="history-asset-link" to={`/inventarios/${version.assetId}`}><strong>{version.assetName}</strong><small>{version.assetCode} · {version.typeName}</small></Link></td><td><span className={`version-badge ${version.isCurrent ? 'current' : ''}`}>v{version.versionNumber}{version.isCurrent ? ' · actual' : ''}</span></td><td><strong>{assetVersionChangeLabel(version.changeType)}</strong><small className="cell-subtext">{version.source}</small></td><td><div className="tag-list">{version.changedFields.slice(0, 3).map((field) => <span className="tag" key={field}>{assetVersionFieldLabel(field)}</span>)}</div></td><td>{version.actorUsername ?? 'Sistema'}</td><td>{assetStatusLabel(version.informationStatus)}</td><td><button type="button" className="icon-button" onClick={() => open(version)} aria-label={`Ver versión ${version.versionNumber}`}><Icon name="chevron" /></button></td></tr>)}</tbody></table></div><div className="pagination"><button type="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 type="button" className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
|
||||
{loading ? <LoadingBlock label="Consultando versiones…" /> : versions.length === 0 ? <EmptyState title="Sin versiones" text="No hay versiones históricas para los filtros seleccionados." /> : <div className="table-panel history-table"><div className="table-summary"><strong>{meta.total} versiones registradas</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><SortableTable><thead><tr><th>Fecha y hora</th><th>Registro</th><th>Versión</th><th>Cambio</th><th>Campos</th><th>Usuario</th><th>Estado</th><th /></tr></thead><tbody>{versions.map((version) => <tr key={version.id}><td>{formatDate(version.occurredAt)}</td><td><Link className="history-asset-link" to={`/inventarios/${version.assetId}`}><strong>{version.assetName}</strong><small>{version.assetCode} · {version.typeName}</small></Link></td><td><span className={`version-badge ${version.isCurrent ? 'current' : ''}`}>v{version.versionNumber}{version.isCurrent ? ' · actual' : ''}</span></td><td><strong>{assetVersionChangeLabel(version.changeType)}</strong><small className="cell-subtext">{version.source}</small></td><td><div className="tag-list">{version.changedFields.slice(0, 3).map((field) => <span className="tag" key={field}>{assetVersionFieldLabel(field)}</span>)}</div></td><td>{version.actorUsername ?? 'Sistema'}</td><td>{assetStatusLabel(version.informationStatus)}</td><td><button type="button" className="icon-button" onClick={() => open(version)} aria-label={`Ver versión ${version.versionNumber}`}><Icon name="chevron" /></button></td></tr>)}</tbody></SortableTable></div><div className="pagination"><button type="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 type="button" className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
|
||||
|
||||
{(detailLoading || detail) && <AssetVersionDrawer detail={detail} loading={detailLoading} onClose={() => setDetail(null)} />}
|
||||
</section>;
|
||||
|
||||
@@ -80,10 +80,23 @@ export function InspectionActEditorPage() {
|
||||
<div className="act-document-primary-copy"><span className="asset-symbol"><Icon name="clipboard" /></span><div><span className="eyebrow">DOCUMENTO DEL ACTA</span><h2>{isSealed ? 'Acta consolidada disponible' : 'Acta en preparación'}</h2><p>{isSealed ? 'Abrí o descargá el Acta firmada, con sus Hallazgos y constancias de integridad.' : 'El documento definitivo se genera al firmar y cerrar el Acta.'}</p></div></div>
|
||||
<div className="act-primary-actions">{isSealed ? <><button className="button primary" type="button" disabled={pdfBusy} onClick={() => void openActPdf(false)}>{pdfBusy ? 'Preparando…' : 'Abrir PDF del Acta'}</button><button className="button secondary" type="button" disabled={pdfBusy} onClick={() => void openActPdf(true)}>Descargar PDF</button></> : <span className="status-badge pending">{inspectionActStatusLabel(act.status)}</span>}</div>
|
||||
</section>}
|
||||
{act?.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Informe relacionado: <Link to={`/informes/${act.report.id}`}>{act.report.code}</Link>.</strong> {act.report.pdfStatus === 'READY' ? ' Disponible.' : ' En preparación.'}</p></div>}
|
||||
{act?.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Informe relacionado: <Link to={`/informes/${act.report.id}`}>{act.report.code}</Link>.</strong> {act.report.status === 'OFFICIALIZED' ? ` Oficializado en GEDO${act.report.gedoIfIdentifier ? ` · ${act.report.gedoIfIdentifier}` : ''}.` : act.report.status === 'FROZEN' ? ' Informe consolidado.' : act.report.wordStatus === 'READY' ? ' INF listo para enviar a GEDO.' : ' En preparación.'}</p></div>}
|
||||
{isSealed && act && !act.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>INF pendiente de emisión.</strong> El Acta ya está sellada y disponible como documento fuente.</p></div>}
|
||||
{act?.status === 'CANCELLED' && <Alert>Cancelada: {act.cancellationReason}</Alert>}
|
||||
|
||||
{act && <section className="panel inspection-act-context">
|
||||
<div className="panel-heading"><div><span className="eyebrow">CONTEXTO DEL ACTA</span><h2>Ubicación territorial y operativa</h2><p className="section-copy">El Acta corresponde a un Yacimiento y los Hallazgos se ubican dentro de sus Instalaciones y Subinstalaciones.</p></div></div>
|
||||
<div className="responsible-summary">
|
||||
<div><small>Departamento</small><strong>{act.context.department?.name ?? 'Sin definir'}</strong>{act.context.department && <span>{act.context.department.code}</span>}</div>
|
||||
<div><small>Área</small><strong>{act.context.area?.name ?? 'Sin definir'}</strong>{act.context.area && <span>{act.context.area.code}</span>}</div>
|
||||
<div><small>Yacimiento</small><strong>{act.context.yacimiento?.name ?? 'No definido en la Inspección histórica'}</strong>{act.context.yacimiento && <span>{act.context.yacimiento.code}</span>}</div>
|
||||
<div><small>Empresa / Operadora</small><strong>{act.context.company?.name ?? 'Sin definir'}</strong>{act.context.company && <span>{act.context.company.code}</span>}</div>
|
||||
<div><small>Instalaciones con Hallazgos</small><strong>{act.context.installations.length ? act.context.installations.map((item) => item.name).join(' · ') : 'Ninguna'}</strong></div>
|
||||
<div><small>Subinstalaciones con Hallazgos</small><strong>{act.context.subinstallations.length ? act.context.subinstallations.map((item) => item.name).join(' · ') : 'Ninguna'}</strong></div>
|
||||
</div>
|
||||
{act.context.legacyAreaScope && !act.context.yacimiento && <Alert type="info">Esta Acta pertenece a una Inspección histórica creada antes de exigir Yacimiento como alcance. El Área se conserva como fue registrada; no se la presenta como Yacimiento.</Alert>}
|
||||
</section>}
|
||||
|
||||
{act && <section className="panel inspection-act-form">
|
||||
<div className="responsible-summary">
|
||||
<div><small>Fecha de inspección</small><strong>{formatDate(act.occurredAt)}</strong></div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../components/SortableTable';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
@@ -357,14 +358,14 @@ export function InspectionVisitEditorF4Page() {
|
||||
<div className="panel-heading"><div><span className="eyebrow">CHECKLIST TÉCNICO</span><h2>Antecedentes y controles</h2><p className="section-copy">Se construye para el Área, Yacimiento y Operadora fijados en la Inspección, tomando la fecha planificada. Las respuestas administrativas de empresa no modifican este checklist.</p></div>{canManage && planningEditable && <button type="button" className="button secondary" onClick={() => void regenerateChecklist()} disabled={busy}>Regenerar checklist</button>}</div>
|
||||
{visit.checklist.stale && <Alert>La fecha cambió. Regenerá el checklist antes de planificar.</Alert>}
|
||||
<div className="inspection-checklist-metrics"><div><strong>{visit.checklist.verificationOverdue}</strong><span>controles vencidos</span></div><div><strong>{visit.checklist.upcomingControls}</strong><span>próximos 30 días</span></div><div><strong>{visit.checklist.antecedents}</strong><span>antecedentes</span></div><div><strong>{visit.checklist.actionableAssets}</strong><span>registros sugeridos</span></div></div>
|
||||
{visit.checklist.items.length === 0 ? <EmptyState title="Sin antecedentes" text="No hay Hallazgos históricos técnicos para este contexto." /> : <div className="table-scroll"><table><thead><tr><th>Tipo</th><th>Hallazgo</th><th>Inventario</th><th>Fecha</th><th>Gravedad</th></tr></thead><tbody>{visit.checklist.items.map((item) => <tr key={item.id}><td><span className={`status-badge ${item.itemKind === 'ANTECEDENT' ? '' : 'warning'}`}>{checklistLabel(item.itemKind)}</span></td><td><Link className="history-asset-link" to={`/hallazgos/${item.findingId}`}><strong>{item.findingTitle}</strong><small>{item.findingCode}</small></Link></td><td><Link className="history-asset-link" to={`/inventarios/${item.asset.id}`}><strong>{item.asset.name}</strong><small>{item.asset.code} · {item.asset.typeName}</small></Link></td><td>{formatDateOnly(item.referenceOn)}</td><td>{item.severity ?? '—'}</td></tr>)}</tbody></table></div>}
|
||||
{visit.checklist.items.length === 0 ? <EmptyState title="Sin antecedentes" text="No hay Hallazgos históricos técnicos para este contexto." /> : <div className="table-scroll"><SortableTable><thead><tr><th>Tipo</th><th>Hallazgo</th><th>Inventario</th><th>Fecha</th><th>Gravedad</th></tr></thead><tbody>{visit.checklist.items.map((item) => <tr key={item.id}><td><span className={`status-badge ${item.itemKind === 'ANTECEDENT' ? '' : 'warning'}`}>{checklistLabel(item.itemKind)}</span></td><td><Link className="history-asset-link" to={`/hallazgos/${item.findingId}`}><strong>{item.findingTitle}</strong><small>{item.findingCode}</small></Link></td><td><Link className="history-asset-link" to={`/inventarios/${item.asset.id}`}><strong>{item.asset.name}</strong><small>{item.asset.code} · {item.asset.typeName}</small></Link></td><td>{formatDateOnly(item.referenceOn)}</td><td>{item.severity ?? '—'}</td></tr>)}</tbody></SortableTable></div>}
|
||||
</article>}
|
||||
|
||||
{visit && visit.verificationFindings.length > 0 && <article className="panel verification-visit-findings"><div className="panel-heading"><div><span className="eyebrow">VERIFICACIÓN</span><h2>Hallazgos a controlar</h2></div><span className="count-pill">{visit.verificationFindings.length}</span></div><div className="dossier-link-list">{visit.verificationFindings.map((finding) => <Link key={finding.id} to={`/hallazgos/${finding.id}`}><div><strong>{finding.title}</strong><small>{finding.code} · {finding.assetName} · objetivo {formatDateOnly(finding.targetControlOn ?? finding.nextControlOn)}</small>{finding.resultNotes && <small>{finding.resultNotes}</small>}</div><span>{finding.outcome === 'RESOLVED' ? 'Solucionado' : finding.outcome === 'NOT_RESOLVED' ? 'No solucionado' : finding.outcome === 'REQUIRES_NEW_DATE' ? 'Reprogramar' : 'Pendiente'}</span><Icon name="chevron" size={16} /></Link>)}</div></article>}
|
||||
|
||||
{visit && planningEditable && canManage && <form className="panel survey-add-target" onSubmit={addPreventiveAsset}><div className="panel-heading"><div><span className="eyebrow">PREVENTIVO</span><h2>Agregar Inventario sin pendiente previo</h2><p className="section-copy">Sólo se muestran Instalaciones y Subinstalaciones pertenecientes al Yacimiento fijado para esta Inspección.</p></div></div><label className="field"><span>Buscar Inventario</span><input value={assetSearch} onChange={(event) => setAssetSearch(event.target.value)} placeholder="Código o nombre" /></label><label className="field"><span>Inventario</span><SearchableSelect value={newAssetId} onChange={(event) => setNewAssetId(event.target.value)} required><option value="">Seleccionar…</option>{candidateAssets.map((asset) => <option key={asset.id} value={asset.id}>{asset.code} · {asset.name} · {asset.typeName}</option>)}</SearchableSelect></label><div className="form-actions"><button className="button primary" disabled={busy || !newAssetId}><Icon name="plus" />Agregar preventivo</button></div></form>}
|
||||
|
||||
{visit && <article className="table-panel inspection-assets"><div className="table-summary"><strong>{visit.assets.length} incluidos</strong><span>{visit.checklist.excludedAssets} excluidos con trazabilidad</span></div>{visit.planningAssets.length === 0 ? <EmptyState title="Sin Inventarios" text="Generá el checklist o agregá un preventivo." /> : <div className="table-scroll"><table><thead><tr><th>Inventario</th><th>Origen</th><th>Estado</th><th>Motivo</th><th /></tr></thead><tbody>{visit.planningAssets.map((asset) => <tr key={asset.id}><td><Link className="history-asset-link" to={`/inventarios/${asset.id}`}><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></Link></td><td>{sourceLabel(asset.planningSource)}</td><td>{asset.included ? 'Incluido' : 'Excluido'}</td><td>{asset.exclusionReason ?? '—'}</td><td>{canManage && planningEditable && (asset.included ? <button type="button" className="button danger-outline compact" onClick={() => void excludeAsset(asset.id)}>Excluir</button> : <button type="button" className="button secondary compact" onClick={() => void reincludeAsset(asset.id)}>Reincorporar</button>)}</td></tr>)}</tbody></table></div>}</article>}
|
||||
{visit && <article className="table-panel inspection-assets"><div className="table-summary"><strong>{visit.assets.length} incluidos</strong><span>{visit.checklist.excludedAssets} excluidos con trazabilidad</span></div>{visit.planningAssets.length === 0 ? <EmptyState title="Sin Inventarios" text="Generá el checklist o agregá un preventivo." /> : <div className="table-scroll"><SortableTable><thead><tr><th>Inventario</th><th>Origen</th><th>Estado</th><th>Motivo</th><th /></tr></thead><tbody>{visit.planningAssets.map((asset) => <tr key={asset.id}><td><Link className="history-asset-link" to={`/inventarios/${asset.id}`}><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></Link></td><td>{sourceLabel(asset.planningSource)}</td><td>{asset.included ? 'Incluido' : 'Excluido'}</td><td>{asset.exclusionReason ?? '—'}</td><td>{canManage && planningEditable && (asset.included ? <button type="button" className="button danger-outline compact" onClick={() => void excludeAsset(asset.id)}>Excluir</button> : <button type="button" className="button secondary compact" onClick={() => void reincludeAsset(asset.id)}>Reincorporar</button>)}</td></tr>)}</tbody></SortableTable></div>}</article>}
|
||||
|
||||
{visit && canAssign && planningEditable && <form className="panel inspection-team-panel" onSubmit={saveTeam}><div className="panel-heading"><div><span className="eyebrow">EQUIPO</span><h2>Inspectores asignados</h2></div></div><label className="field"><span>Inspector responsable</span><SearchableSelect value={leadInspectorId} onChange={(event) => chooseLead(event.target.value)}><option value="">Seleccionar…</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)} · {person.username}</option>)}</SearchableSelect></label><div className="inspection-team-grid">{assignees.map((person) => <label className={`inspection-member ${memberIds.has(person.id) ? 'selected' : ''}`} key={person.id}><input type="checkbox" checked={memberIds.has(person.id)} onChange={() => setMemberIds((current) => { const next = new Set(current); next.has(person.id) ? next.delete(person.id) : next.add(person.id); return next; })} disabled={person.id === leadInspectorId} /><span><strong>{personName(person)}</strong><small>{person.username}{person.id === leadInspectorId ? ' · Responsable' : ''}</small></span></label>)}</div><div className="form-actions"><button className="button primary" disabled={busy || !leadInspectorId}>Guardar equipo</button></div></form>}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../components/SortableTable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
@@ -106,6 +107,6 @@ export function InspectionVisitsPage() {
|
||||
</form>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label="Cargando inspecciones…" /> : visits.length === 0 ? <EmptyState title="Sin inspecciones" text="No hay inspecciones que coincidan con el estado y contexto seleccionados." /> : <div className="table-panel"><div className="table-summary"><strong>{meta.total} inspección{meta.total === 1 ? '' : 'es'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><table><thead><tr><th>Inspección</th><th>Estado</th><th>Área / Operadora</th><th>Responsable</th><th>Fecha prevista</th><th>Inventario</th><th /></tr></thead><tbody>{visits.map((visit) => <tr key={visit.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{visit.code}</strong><small>{visit.operationalArea?.name ?? 'Sin Área'} · {visit.operatorCompany?.name ?? 'Sin Operadora'}</small></div></div></td><td><span className={`status-badge ${inspectionStatusClass(visit.status)}`}>{inspectionVisitStatusLabel(visit.status)}</span></td><td>{visit.operationalArea || visit.operatorCompany ? <span><strong className="table-primary">{visit.operationalArea?.name ?? 'Sin Área'}</strong><small className="cell-subtext">{visit.operatorCompany?.name ?? 'Sin Operadora'}</small></span> : <span className="muted">Sin contexto</span>}</td><td>{visit.leadInspector ? `${visit.leadInspector.firstName} ${visit.leadInspector.lastName}` : 'Sin asignar'}</td><td><span className="survey-date-range">{formatDate(visit.plannedStartAt)}<small>inicio planificado</small></span></td><td>{visit.scopeAsset ? <Link className="text-link" to={`/inventarios/${visit.scopeAsset.id}`}>{visit.scopeAsset.name}<small className="block-muted">{visit.scopeAsset.code}</small></Link> : <><strong>{visit.assetCount} registro{visit.assetCount === 1 ? '' : 's'}</strong><small className="block-muted">{visit.memberCount} integrante{visit.memberCount === 1 ? '' : 's'}</small></>}</td><td className="action-cell"><Link className="icon-button" to={`/inspecciones/${visit.id}`} aria-label={`Abrir ${visit.code}`}><Icon name="chevron" /></Link></td></tr>)}</tbody></table></div><div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
|
||||
{loading ? <LoadingBlock label="Cargando inspecciones…" /> : visits.length === 0 ? <EmptyState title="Sin inspecciones" text="No hay inspecciones que coincidan con el estado y contexto seleccionados." /> : <div className="table-panel"><div className="table-summary"><strong>{meta.total} inspección{meta.total === 1 ? '' : 'es'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><SortableTable><thead><tr><th>Inspección</th><th>Estado</th><th>Área / Operadora</th><th>Responsable</th><th>Fecha prevista</th><th>Inventario</th><th /></tr></thead><tbody>{visits.map((visit) => <tr key={visit.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{visit.code}</strong><small>{visit.operationalArea?.name ?? 'Sin Área'} · {visit.operatorCompany?.name ?? 'Sin Operadora'}</small></div></div></td><td><span className={`status-badge ${inspectionStatusClass(visit.status)}`}>{inspectionVisitStatusLabel(visit.status)}</span></td><td>{visit.operationalArea || visit.operatorCompany ? <span><strong className="table-primary">{visit.operationalArea?.name ?? 'Sin Área'}</strong><small className="cell-subtext">{visit.operatorCompany?.name ?? 'Sin Operadora'}</small></span> : <span className="muted">Sin contexto</span>}</td><td>{visit.leadInspector ? `${visit.leadInspector.firstName} ${visit.leadInspector.lastName}` : 'Sin asignar'}</td><td><span className="survey-date-range">{formatDate(visit.plannedStartAt)}<small>inicio planificado</small></span></td><td>{visit.scopeAsset ? <Link className="text-link" to={`/inventarios/${visit.scopeAsset.id}`}>{visit.scopeAsset.name}<small className="block-muted">{visit.scopeAsset.code}</small></Link> : <><strong>{visit.assetCount} registro{visit.assetCount === 1 ? '' : 's'}</strong><small className="block-muted">{visit.memberCount} integrante{visit.memberCount === 1 ? '' : 's'}</small></>}</td><td className="action-cell"><Link className="icon-button" to={`/inspecciones/${visit.id}`} aria-label={`Abrir ${visit.code}`}><Icon name="chevron" /></Link></td></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>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -1,74 +1,96 @@
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { AssetCenterTabs } from '../features/assets/AssetCenterTabs';
|
||||
import { DhMap } from '../features/map/DhMap';
|
||||
import {
|
||||
assetStatusClass,
|
||||
assetStatusLabel,
|
||||
ASSET_STATUSES,
|
||||
} from '../features/assets/assetPresentation';
|
||||
import { getMapAssets, listAssetTypes } from '../lib/api';
|
||||
import type {
|
||||
AssetGeometryType,
|
||||
AssetInformationStatus,
|
||||
AssetType,
|
||||
MapAssetFeatureCollection,
|
||||
} from '../lib/api';
|
||||
import { assetStatusClass, assetStatusLabel } from '../features/assets/assetPresentation';
|
||||
import { getMapAssets, getMapDocuments, getMapOperationalContext } from '../lib/api';
|
||||
import type { MapAssetFeatureCollection, MapEntityKind } from '../lib/api';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
const emptyCollection: MapAssetFeatureCollection = {
|
||||
type: 'FeatureCollection', features: [], meta: { count: 0, truncated: false },
|
||||
};
|
||||
const kindLabels: Record<MapEntityKind, string> = {
|
||||
ASSET: 'Inventario GPS', YACIMIENTO: 'Yacimiento', COMPANY: 'Empresa', ACT: 'Acta', FINDING: 'Hallazgo',
|
||||
};
|
||||
|
||||
export function MapPage() {
|
||||
const [types, setTypes] = useState<AssetType[]>([]);
|
||||
const [data, setData] = useState<MapAssetFeatureCollection>(emptyCollection);
|
||||
const [typeId, setTypeId] = useState('');
|
||||
const [status, setStatus] = useState<AssetInformationStatus | ''>('');
|
||||
const [geometryType, setGeometryType] = useState<AssetGeometryType | ''>('');
|
||||
const { hasPermission } = useAuth();
|
||||
const canReadDocuments = hasPermission('inspection_acts.read') && hasPermission('inspection_findings.read');
|
||||
const [assets, setAssets] = useState<MapAssetFeatureCollection>(emptyCollection);
|
||||
const [context, setContext] = useState<MapAssetFeatureCollection>(emptyCollection);
|
||||
const [documents, setDocuments] = useState<MapAssetFeatureCollection>(emptyCollection);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
listAssetTypes().then(setTypes).catch(() => undefined);
|
||||
}, []);
|
||||
setLoading(true); setError('');
|
||||
Promise.all([
|
||||
getMapAssets(),
|
||||
getMapOperationalContext(),
|
||||
canReadDocuments ? getMapDocuments() : Promise.resolve(emptyCollection),
|
||||
]).then(([assetResult, contextResult, documentResult]) => {
|
||||
setAssets(assetResult); setContext(contextResult); setDocuments(documentResult);
|
||||
}).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
||||
}, [canReadDocuments]);
|
||||
|
||||
const data = useMemo<MapAssetFeatureCollection>(() => {
|
||||
const directAssets = assets.features.filter((feature) => {
|
||||
const code = feature.properties.typeCode?.toLowerCase() ?? '';
|
||||
return !['yacimiento', 'empresa'].includes(code) && feature.geometry.type.toLowerCase() === 'point';
|
||||
});
|
||||
const features = [...directAssets, ...context.features, ...documents.features]
|
||||
.filter((feature) => feature.geometry.type.toLowerCase() === 'point');
|
||||
return { type: 'FeatureCollection', features, meta: { count: features.length, truncated: assets.meta.truncated } };
|
||||
}, [assets, context, documents]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true); setError('');
|
||||
getMapAssets({ typeId, status, geometryType })
|
||||
.then((result) => {
|
||||
setData(result);
|
||||
setSelectedId((current) => result.features.some((item) => item.id === current) ? current : null);
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [typeId, status, geometryType]);
|
||||
setSelectedId((current) => data.features.some((item) => item.id === current) ? current : null);
|
||||
}, [data]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => data.features.find((feature) => feature.id === selectedId) ?? null,
|
||||
[data, selectedId],
|
||||
);
|
||||
const selected = useMemo(() => data.features.find((feature) => feature.id === selectedId) ?? null, [data, selectedId]);
|
||||
const kind = selected?.properties.entityKind ?? 'ASSET';
|
||||
|
||||
return <section>
|
||||
<div className="page-heading"><div><span className="eyebrow">INVENTARIOS</span><h1>Mapa de inventarios</h1><p>Vista territorial de las ubicaciones registradas.</p></div><span className="map-count"><strong>{data.meta.count}</strong> geometría{data.meta.count === 1 ? '' : 's'}</span></div>
|
||||
<div className="page-heading">
|
||||
<div><span className="eyebrow">TERRITORIO Y OPERACIÓN</span><h1>Mapa operativo</h1><p>Ubicaciones GPS de Yacimientos, Empresas, Actas, Hallazgos e Inventario.</p></div>
|
||||
<span className="map-count"><strong>{data.meta.count}</strong> punto{data.meta.count === 1 ? '' : 's'} GPS</span>
|
||||
</div>
|
||||
<AssetCenterTabs active="map" />
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{data.meta.truncated && <Alert type="info">Se muestran los primeros 5000 registros. Aplicá filtros para reducir el resultado.</Alert>}
|
||||
<div className="map-layout operational-map-layout">
|
||||
<aside className="filters map-sidebar">
|
||||
<div><span className="eyebrow">FILTROS</span><h2>Vista territorial</h2></div>
|
||||
<label className="field"><span>Tipo de elemento</span><SearchableSelect value={typeId} onChange={(event) => setTypeId(event.target.value)}><option value="">Todos</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
|
||||
<label className="field"><span>Estado de información</span><SearchableSelect value={status} onChange={(event) => setStatus(event.target.value as AssetInformationStatus | '')}><option value="">Todos</option>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
|
||||
<label className="field"><span>Geometría</span><SearchableSelect value={geometryType} onChange={(event) => setGeometryType(event.target.value as AssetGeometryType | '')}><option value="">Todas</option><option value="POINT">Puntos</option><option value="LINESTRING">Líneas</option><option value="POLYGON">Polígonos</option></SearchableSelect></label>
|
||||
<button className="button secondary wide" onClick={() => { setTypeId(''); setStatus(''); setGeometryType(''); }}>Limpiar filtros</button>
|
||||
{data.meta.truncated && <Alert type="info">Se muestran los primeros 5000 puntos GPS de Inventario.</Alert>}
|
||||
|
||||
{selected && <div className="map-selection"><span className="eyebrow">REGISTRO SELECCIONADO</span><h3>{selected.properties.name}</h3><code>{selected.properties.code}</code><div className="map-selection-meta"><span className="tag">{selected.properties.typeName}</span><span className={`status-badge ${assetStatusClass(selected.properties.informationStatus)}`}>{assetStatusLabel(selected.properties.informationStatus)}</span></div>{selected.properties.parentName && <p>Depende de <strong>{selected.properties.parentName}</strong></p>}<p>{selected.properties.geometryType === 'POINT' ? 'Punto' : selected.properties.geometryType === 'LINESTRING' ? 'Línea' : 'Polígono'} · actualizado {formatDate(selected.properties.updatedAt)}</p>{selected.properties.accuracyM != null && <p>Precisión informada: {selected.properties.accuracyM} m</p>}<Link className="button primary wide" to={`/inventarios/${selected.id}`}>Abrir registro <Icon name="chevron" /></Link></div>}
|
||||
</aside>
|
||||
<div className="map-stage">{loading && <div className="map-loading"><LoadingBlock label="Actualizando mapa…" /></div>}<DhMap data={data} selectedId={selectedId} onSelect={setSelectedId} />{!loading && data.features.length === 0 && <div className="map-empty"><Icon name="map" size={30} /><strong>No hay geometrías para mostrar</strong><span>Agregá una ubicación desde el detalle de un registro.</span></div>}</div>
|
||||
<div className="map-legend" aria-label="Referencias del mapa">
|
||||
{(Object.entries(kindLabels) as Array<[MapEntityKind, string]>).map(([item, label]) => (
|
||||
<span key={item}><i className={`map-legend-dot kind-${item.toLowerCase()}`} />{label}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="map-stage operational-map-stage">
|
||||
{loading && <div className="map-loading"><LoadingBlock label="Cargando puntos GPS…" /></div>}
|
||||
<DhMap data={data} selectedId={selectedId} onSelect={setSelectedId} />
|
||||
{!loading && data.features.length === 0 && <div className="map-empty"><Icon name="map" size={30} /><strong>No hay puntos GPS para mostrar</strong><span>Los registros aparecen cuando cuentan con una ubicación GPS propia o derivable del Yacimiento.</span></div>}
|
||||
{selected && <div className="map-selection map-selection-overlay">
|
||||
<span className="eyebrow">{kindLabels[kind]}</span>
|
||||
<h3>{selected.properties.name}</h3>
|
||||
<code>{selected.properties.code}</code>
|
||||
<div className="map-selection-meta">
|
||||
<span className="tag">{selected.properties.typeName}</span>
|
||||
{kind === 'ASSET' && selected.properties.informationStatus && <span className={`status-badge ${assetStatusClass(selected.properties.informationStatus)}`}>{assetStatusLabel(selected.properties.informationStatus)}</span>}
|
||||
</div>
|
||||
{selected.properties.departmentName && <p><strong>Departamento:</strong> {selected.properties.departmentName}</p>}
|
||||
{selected.properties.areaName && <p><strong>Área:</strong> {selected.properties.areaName}</p>}
|
||||
{selected.properties.yacimientoName && <p><strong>Yacimiento:</strong> {selected.properties.yacimientoName}</p>}
|
||||
{selected.properties.companyName && <p><strong>Empresa:</strong> {selected.properties.companyName}</p>}
|
||||
{selected.properties.assetName && <p><strong>Elemento:</strong> {selected.properties.assetName}</p>}
|
||||
<p>GPS actualizado {formatDate(selected.properties.updatedAt)}</p>
|
||||
{selected.properties.sourceGeometries != null && <p><small>Referencia derivada de {selected.properties.sourceGeometries} punto{selected.properties.sourceGeometries === 1 ? '' : 's'} GPS registrado{selected.properties.sourceGeometries === 1 ? '' : 's'} en el Yacimiento.</small></p>}
|
||||
{selected.properties.href && <Link className="button primary wide" to={selected.properties.href}>Abrir registro <Icon name="chevron" /></Link>}
|
||||
</div>}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { SortableTable } from '../components/SortableTable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { actCompanyResponseContentUrl, getActAdministration } from '../lib/api';
|
||||
import type { ActAdministrationDetail } from '../lib/api';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { formatDate } from '../lib/format';
|
||||
import { formatDate, formatDateOnly } from '../lib/format';
|
||||
import {
|
||||
addInspectionReportCompanyResponse,
|
||||
addInspectionReportFollowUp,
|
||||
getInspectionReportF4,
|
||||
inspectionReportGedoPdfDownloadUrl,
|
||||
inspectionReportFollowUpDownloadUrl,
|
||||
inspectionReportCompanyResponseDownloadUrl,
|
||||
inspectionReportConsolidatedWordDownloadUrl,
|
||||
inspectionReportFollowUpDownloadUrl,
|
||||
inspectionReportGedoPdfDownloadUrl,
|
||||
inspectionReportWordDownloadUrl,
|
||||
listInspectionReportFollowUps,
|
||||
officializeInspectionReport,
|
||||
setInspectionReportResponseDeadline,
|
||||
updateInspectionReportNarrative,
|
||||
} from '../lib/reportWorkflowApi';
|
||||
import type {
|
||||
@@ -34,6 +36,11 @@ function localDateTime(value: Date): string {
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function localDate(value = new Date()): string {
|
||||
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function reportStatusLabel(status: InspectionReportDetailF4['status']): string {
|
||||
if (status === 'WORKING') return 'En preparación';
|
||||
if (status === 'OFFICIALIZED') return 'Oficializado en GEDO';
|
||||
@@ -66,10 +73,8 @@ export function ReportDetailPage() {
|
||||
const { id } = useParams();
|
||||
const { hasPermission } = useAuth();
|
||||
const canManage = hasPermission('inspection_reports.generate');
|
||||
const canReadActHistory = hasPermission('inspection_acts.read');
|
||||
const [report, setReport] = useState<InspectionReportDetailF4 | null>(null);
|
||||
const [followUps, setFollowUps] = useState<InspectionReportFollowUp[]>([]);
|
||||
const [legacy, setLegacy] = useState<ActAdministrationDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -77,12 +82,21 @@ export function ReportDetailPage() {
|
||||
|
||||
const [executiveSummary, setExecutiveSummary] = useState('');
|
||||
const [reportDescription, setReportDescription] = useState('');
|
||||
|
||||
const [gedoIfIdentifier, setGedoIfIdentifier] = useState('');
|
||||
const [gedoOfficializedAt, setGedoOfficializedAt] = useState(localDateTime(new Date()));
|
||||
const [gedoFile, setGedoFile] = useState<File | null>(null);
|
||||
|
||||
const [followUpType, setFollowUpType] = useState<InspectionReportFollowUpType>('COMPANY_NOTE');
|
||||
const [responseDueOn, setResponseDueOn] = useState('');
|
||||
const [deadlineReason, setDeadlineReason] = useState('');
|
||||
|
||||
const [companyReceivedOn, setCompanyReceivedOn] = useState(localDate());
|
||||
const [companyDetails, setCompanyDetails] = useState('');
|
||||
const [companyCommittedOn, setCompanyCommittedOn] = useState('');
|
||||
const [companyContactName, setCompanyContactName] = useState('');
|
||||
const [companyContactEmail, setCompanyContactEmail] = useState('');
|
||||
const [companyResponseFile, setCompanyResponseFile] = useState<File | null>(null);
|
||||
|
||||
const [followUpType, setFollowUpType] = useState<InspectionReportFollowUpType>('INTERNAL_NOTE');
|
||||
const [followUpOccurredAt, setFollowUpOccurredAt] = useState(localDateTime(new Date()));
|
||||
const [followUpReference, setFollowUpReference] = useState('');
|
||||
const [followUpDescription, setFollowUpDescription] = useState('');
|
||||
@@ -96,73 +110,80 @@ export function ReportDetailPage() {
|
||||
]);
|
||||
setReport(nextReport);
|
||||
setFollowUps(nextFollowUps);
|
||||
if (canReadActHistory) setLegacy(await getActAdministration(nextReport.actId).catch(() => null));
|
||||
setExecutiveSummary(nextReport.executiveSummary ?? '');
|
||||
setReportDescription(nextReport.reportDescription ?? '');
|
||||
setGedoIfIdentifier(nextReport.gedoIfIdentifier ?? '');
|
||||
if (nextReport.gedoOfficializedAt) {
|
||||
setGedoOfficializedAt(localDateTime(new Date(nextReport.gedoOfficializedAt)));
|
||||
}
|
||||
if (nextReport.gedoOfficializedAt) setGedoOfficializedAt(localDateTime(new Date(nextReport.gedoOfficializedAt)));
|
||||
setResponseDueOn(nextReport.responseDueOn?.slice(0, 10) ?? '');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
reload()
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
reload().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const saveNarrative = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!id || report?.status !== 'WORKING') return;
|
||||
setWorking(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setWorking(true); setError(''); setSuccess('');
|
||||
try {
|
||||
await updateInspectionReportNarrative(id, {
|
||||
executiveSummary: executiveSummary.trim() || null,
|
||||
description: reportDescription.trim() || null,
|
||||
});
|
||||
await updateInspectionReportNarrative(id, { executiveSummary: executiveSummary.trim() || null, description: reportDescription.trim() || null });
|
||||
await reload();
|
||||
setSuccess('Contenido editable del INF actualizado. El Acta fuente y sus Hallazgos no fueron modificados.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
} catch (requestError) { setError(errorMessage(requestError)); } finally { setWorking(false); }
|
||||
};
|
||||
|
||||
const officialize = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!id || !gedoFile || !gedoIfIdentifier.trim() || !gedoOfficializedAt) return;
|
||||
setWorking(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setWorking(true); setError(''); setSuccess('');
|
||||
try {
|
||||
await officializeInspectionReport(id, {
|
||||
gedoIfIdentifier: gedoIfIdentifier.trim(),
|
||||
gedoOfficializedAt: new Date(gedoOfficializedAt).toISOString(),
|
||||
file: gedoFile,
|
||||
});
|
||||
await officializeInspectionReport(id, { gedoIfIdentifier: gedoIfIdentifier.trim(), gedoOfficializedAt: new Date(gedoOfficializedAt).toISOString(), file: gedoFile });
|
||||
setGedoFile(null);
|
||||
await reload();
|
||||
setSuccess('IF oficial de GEDO registrado. El PDF y su hash quedaron fijados de forma inmutable.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
setSuccess('PDF oficial e identificador IF de GEDO cargados manualmente. El Informe quedó oficializado.');
|
||||
} catch (requestError) { setError(errorMessage(requestError)); } finally { setWorking(false); }
|
||||
};
|
||||
|
||||
const saveDeadline = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!id || report?.status !== 'OFFICIALIZED' || !responseDueOn) return;
|
||||
setWorking(true); setError(''); setSuccess('');
|
||||
try {
|
||||
await setInspectionReportResponseDeadline(id, { responseDueOn, reason: deadlineReason.trim() || null });
|
||||
setDeadlineReason('');
|
||||
await reload();
|
||||
setSuccess(`Vencimiento ${formatDateOnly(responseDueOn)} aplicado a todos los Hallazgos del Acta ${report.act.code}.`);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); } finally { setWorking(false); }
|
||||
};
|
||||
|
||||
const addCompanyResponse = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!id || report?.status !== 'OFFICIALIZED' || !companyReceivedOn) return;
|
||||
if (!companyDetails.trim() && !companyResponseFile) return;
|
||||
setWorking(true); setError(''); setSuccess('');
|
||||
try {
|
||||
await addInspectionReportCompanyResponse(id, {
|
||||
receivedOn: companyReceivedOn,
|
||||
details: companyDetails.trim() || undefined,
|
||||
committedCorrectionOn: companyCommittedOn || undefined,
|
||||
contactName: companyContactName.trim() || undefined,
|
||||
contactEmail: companyContactEmail.trim() || undefined,
|
||||
file: companyResponseFile,
|
||||
});
|
||||
setCompanyDetails(''); setCompanyCommittedOn(''); setCompanyContactName(''); setCompanyContactEmail(''); setCompanyResponseFile(null); setCompanyReceivedOn(localDate());
|
||||
await reload();
|
||||
setSuccess(`Respuesta de empresa registrada y vinculada al Informe ${report.code} y al Acta ${report.act.code}.`);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); } finally { setWorking(false); }
|
||||
};
|
||||
|
||||
const addFollowUp = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!id || !followUpOccurredAt) return;
|
||||
if (!followUpDescription.trim() && !followUpReference.trim() && !followUpFile) return;
|
||||
setWorking(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setWorking(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const next = await addInspectionReportFollowUp(id, {
|
||||
type: followUpType,
|
||||
@@ -171,17 +192,9 @@ export function ReportDetailPage() {
|
||||
description: followUpDescription.trim() || null,
|
||||
file: followUpFile,
|
||||
});
|
||||
setFollowUps(next);
|
||||
setFollowUpReference('');
|
||||
setFollowUpDescription('');
|
||||
setFollowUpFile(null);
|
||||
setFollowUpOccurredAt(localDateTime(new Date()));
|
||||
setSuccess('Antecedente agregado al seguimiento del INF. Los registros anteriores permanecen intactos.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
setFollowUps(next); setFollowUpReference(''); setFollowUpDescription(''); setFollowUpFile(null); setFollowUpOccurredAt(localDateTime(new Date()));
|
||||
setSuccess('Antecedente agregado al historial del Informe.');
|
||||
} catch (requestError) { setError(errorMessage(requestError)); } finally { setWorking(false); }
|
||||
};
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando informe…" />;
|
||||
@@ -190,105 +203,65 @@ export function ReportDetailPage() {
|
||||
{ id: 'act-start', date: report.act.occurredAt, title: `Inspección y Acta ${report.act.code}`, description: `${report.findingCount} hallazgo${report.findingCount === 1 ? '' : 's'} registrados`, href: `/inspecciones/actas/${report.actId}` },
|
||||
...(report.act.sealedAt ? [{ id: 'act-sealed', date: report.act.sealedAt, title: 'Acta firmada y cerrada', description: report.act.code, href: `/inspecciones/actas/${report.actId}` }] : []),
|
||||
{ id: 'report-issued', date: report.generatedAt, title: `Informe ${report.code} preparado`, description: 'Documento técnico vinculado al Acta' },
|
||||
...(report.gedoOfficializedAt ? [{ id: 'gedo', date: report.gedoOfficializedAt, title: 'Informe oficializado en GEDO', description: report.gedoIfIdentifier ?? '' }] : []),
|
||||
...(report.gedoOfficializedAt ? [{ id: 'gedo', date: report.gedoOfficializedAt, title: 'PDF oficial de GEDO cargado', description: report.gedoIfIdentifier ?? '' }] : []),
|
||||
...report.deadlines.map((item) => ({ id: `deadline-${item.id}`, date: item.createdAt, title: 'Vencimiento general del Acta', description: `${formatDateOnly(item.responseDueOn)} · ${item.reason}` })),
|
||||
...report.companyResponses.map((item) => ({ id: `response-${item.id}`, date: `${item.receivedOn}T12:00:00`, title: `Respuesta de empresa · Acta ${report.act.code}`, description: item.details ?? item.originalName ?? 'Respuesta registrada', href: item.originalName ? inspectionReportCompanyResponseDownloadUrl(report.id, item.id) : undefined, fileName: item.originalName ?? undefined })),
|
||||
...followUps.map((item) => ({ id: item.id, date: item.occurredAt, title: followUpLabel(item.type), description: item.description || item.externalReference || item.originalName || 'Antecedente registrado', href: item.originalName ? inspectionReportFollowUpDownloadUrl(report.id, item.id) : undefined, fileName: item.originalName ?? undefined })),
|
||||
...(legacy?.responses.map((item) => ({ id: `legacy-${item.id}`, date: item.receivedOn, title: 'Respuesta de empresa registrada previamente', description: item.details ?? 'Sin detalle', href: item.originalName ? actCompanyResponseContentUrl(item.id) : undefined, fileName: item.originalName ?? undefined })) ?? []),
|
||||
...(legacy?.deadlines.map((item) => ({ id: `deadline-${item.id}`, date: item.createdAt, title: 'Plazo administrativo registrado previamente', description: `${formatDate(item.responseDueOn)} · ${item.reason}` })) ?? []),
|
||||
].sort((a, b) => b.date.localeCompare(a.date)) : [];
|
||||
|
||||
return <section>
|
||||
<div className="breadcrumb"><Link to="/informes">Informes</Link><span>/</span><span>{report?.code ?? 'Informe'}</span></div>
|
||||
<div className="page-heading survey-editor-heading">
|
||||
<div><span className="eyebrow">INFORME DE INSPECCIÓN</span><h1>{report?.code ?? 'Informe'}</h1><p>{report ? `Acta ${report.act.code} · generado ${formatDate(report.generatedAt)}` : 'Consulta del informe.'}</p></div>
|
||||
{report && <div className="report-status-stack">
|
||||
<span className={`status-badge large ${report.wordStatus === 'READY' ? 'active' : report.wordStatus === 'FAILED' ? 'danger' : 'pending'}`}>{report.wordStatus === 'READY' ? 'Word disponible' : report.wordStatus === 'FAILED' ? 'Word con error' : 'Word pendiente'}</span>
|
||||
<span className={`status-badge large ${reportStatusClass(report.status)}`}>{reportStatusLabel(report.status)}</span>
|
||||
</div>}
|
||||
{report && <div className="report-status-stack"><span className={`status-badge large ${report.wordStatus === 'READY' ? 'active' : report.wordStatus === 'FAILED' ? 'danger' : 'pending'}`}>{report.wordStatus === 'READY' ? 'Word disponible' : report.wordStatus === 'FAILED' ? 'Word con error' : 'Word pendiente'}</span><span className={`status-badge large ${reportStatusClass(report.status)}`}>{reportStatusLabel(report.status)}</span></div>}
|
||||
</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{success && <Alert type="success">{success}</Alert>}
|
||||
|
||||
{report && <>
|
||||
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un INF corresponde a una sola Acta.</strong> Una Inspección puede contener varias Actas y, por lo tanto, varios INF independientes. El Word puede editarse durante la preparación; el Acta sellada y sus Hallazgos permanecen inmutables.</p></div>
|
||||
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un Informe corresponde a una sola Acta.</strong> GEDO no se consulta automáticamente: la oficialización se registra manualmente cargando el IF y su PDF oficial. Esa carga no genera una respuesta de empresa ni define un vencimiento por sí sola.</p></div>
|
||||
|
||||
<section className="panel report-summary-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD DOCUMENTAL</span><h2>{report.code}</h2></div><small className="muted">Versión del Acta: {report.actVersion}</small></div>
|
||||
<div className="responsible-summary">
|
||||
<div><small>Empresa</small><strong>{names(report.companies, 'Sin asignar')}</strong></div>
|
||||
<div><small>Área / Yacimiento</small><strong>{names(report.areas, 'Sin asignar')}</strong></div>
|
||||
<div><small>Hallazgos</small><strong>{report.findingCount}</strong></div>
|
||||
<div><small>Generado por</small><strong>{report.generatedBy.firstName} {report.generatedBy.lastName}</strong></div>
|
||||
</div>
|
||||
<div className="report-linked-documents">
|
||||
<Link to={`/inspecciones/${report.visitId}`}><span>Inspección</span><strong>{report.visit.code}</strong><Icon name="chevron" /></Link>
|
||||
<Link to={`/inspecciones/actas/${report.actId}`}><span>Acta fuente</span><strong>{report.act.code}</strong><small>Contenido inmutable</small><Icon name="chevron" /></Link>
|
||||
<Link to={`/hallazgos?search=${encodeURIComponent(report.act.code)}`}><span>Hallazgos del Acta</span><strong>{report.findingCount}</strong><small>Seguimiento técnico</small><Icon name="chevron" /></Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">WORD EDITABLE</span><h2>Preparación del INF</h2><p className="section-copy">El Inspector puede revisar y ajustar el texto del Informe antes de incorporarlo a GEDO. Esta edición no altera el Acta fuente.</p></div><div className="act-primary-actions"><a className="button secondary" href={inspectionReportConsolidatedWordDownloadUrl(report.id)}>Descargar Word del informe</a>{report.wordStatus === 'READY' && <a className="button secondary" href={inspectionReportWordDownloadUrl(report.id)}>Word anterior</a>}</div></div>
|
||||
<form className="form-section" onSubmit={saveNarrative}>
|
||||
<label className="field"><span>Resumen ejecutivo</span><textarea rows={4} maxLength={20000} value={executiveSummary} onChange={(event) => setExecutiveSummary(event.target.value)} disabled={!canManage || report.status !== 'WORKING'} placeholder="Resumen ejecutivo del Informe…" /></label>
|
||||
<label className="field"><span>Descripción / análisis técnico</span><textarea rows={8} maxLength={50000} value={reportDescription} onChange={(event) => setReportDescription(event.target.value)} disabled={!canManage || report.status !== 'WORKING'} placeholder="Descripción técnica, análisis y consideraciones del Inspector…" /></label>
|
||||
{canManage && report.status === 'WORKING' && <div className="form-actions"><button className="button primary" disabled={working}>{working ? 'Guardando…' : 'Guardar contenido del INF'}</button></div>}
|
||||
{report.status !== 'WORKING' && <Alert type="info">El contenido editable se cerró al registrar el IF oficial de GEDO.</Alert>}
|
||||
</form>
|
||||
<div className="responsible-summary"><div><small>Empresa</small><strong>{names(report.companies, 'Sin asignar')}</strong></div><div><small>Área / Yacimiento</small><strong>{names(report.areas, 'Sin asignar')}</strong></div><div><small>Hallazgos</small><strong>{report.findingCount}</strong></div><div><small>Generado por</small><strong>{report.generatedBy.firstName} {report.generatedBy.lastName}</strong></div></div>
|
||||
<div className="report-linked-documents"><Link to={`/inspecciones/${report.visitId}`}><span>Inspección</span><strong>{report.visit.code}</strong><Icon name="chevron" /></Link><Link to={`/inspecciones/actas/${report.actId}`}><span>Acta fuente</span><strong>{report.act.code}</strong><small>Contenido inmutable</small><Icon name="chevron" /></Link><Link to={`/hallazgos?search=${encodeURIComponent(report.act.code)}`}><span>Hallazgos del Acta</span><strong>{report.findingCount}</strong><small>Seguimiento técnico</small><Icon name="chevron" /></Link></div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">GEDO</span><h2>Oficialización del Informe</h2><p className="section-copy">Cuando GEDO devuelve el IF y el PDF oficial, ambos se registran en el sistema y pasan a ser la referencia documental institucional.</p></div></div>
|
||||
{report.status === 'OFFICIALIZED' ? <>
|
||||
<div className="responsible-summary">
|
||||
<div><small>Identificador IF</small><strong>{report.gedoIfIdentifier ?? '—'}</strong></div>
|
||||
<div><small>Oficializado</small><strong>{formatDate(report.gedoOfficializedAt)}</strong></div>
|
||||
<div><small>PDF oficial</small><strong>{report.gedoPdfOriginalName ?? 'Registrado'}</strong></div>
|
||||
<div><small>Vencimiento del Acta</small><strong>{formatDate(report.act.deadlineAt)}</strong></div>
|
||||
</div>
|
||||
{report.gedoPdfOriginalName && <div className="form-actions"><a className="button secondary" href={inspectionReportGedoPdfDownloadUrl(report.id)}>Descargar PDF oficial</a></div>}
|
||||
{report.gedoPdfSha256 && <div className="temporal-notice"><Icon name="check" /><p><strong>PDF GEDO fijado.</strong> SHA-256: <code>{report.gedoPdfSha256}</code></p></div>}
|
||||
</> : canManage && report.status === 'WORKING' ? <form className="form-section" onSubmit={officialize}>
|
||||
<div className="form-grid">
|
||||
<label className="field"><span>Identificador IF de GEDO</span><input value={gedoIfIdentifier} onChange={(event) => setGedoIfIdentifier(event.target.value)} required maxLength={255} placeholder="IF-2026-…" /></label>
|
||||
<label className="field"><span>Fecha de oficialización</span><input type="datetime-local" value={gedoOfficializedAt} onChange={(event) => setGedoOfficializedAt(event.target.value)} required /></label>
|
||||
</div>
|
||||
<label className="field"><span>PDF oficial de GEDO</span><input type="file" accept="application/pdf,.pdf" onChange={(event) => setGedoFile(event.target.files?.[0] ?? null)} required /></label>
|
||||
<Alert>Esta acción cierra la edición del INF. El IF, la fecha y el hash del PDF oficial quedarán registrados como trazabilidad institucional.</Alert>
|
||||
<div className="form-actions"><button className="button primary" disabled={working || !gedoFile || !gedoIfIdentifier.trim()}>{working ? 'Registrando…' : 'Registrar IF y PDF oficial'}</button></div>
|
||||
</form> : <Alert type="info">El Informe todavía no está oficializado en GEDO.</Alert>}
|
||||
<div className="panel-heading"><div><span className="eyebrow">WORD EDITABLE</span><h2>Preparación del INF</h2><p className="section-copy">El Inspector puede revisar y ajustar el texto antes de enviarlo a GEDO. Esta edición no altera el Acta fuente.</p></div><div className="act-primary-actions"><a className="button secondary" href={inspectionReportConsolidatedWordDownloadUrl(report.id)}>Descargar Word del informe</a>{report.wordStatus === 'READY' && <a className="button secondary" href={inspectionReportWordDownloadUrl(report.id)}>Word anterior</a>}</div></div>
|
||||
<form className="form-section" onSubmit={saveNarrative}><label className="field"><span>Resumen ejecutivo</span><textarea rows={4} maxLength={20000} value={executiveSummary} onChange={(event) => setExecutiveSummary(event.target.value)} disabled={!canManage || report.status !== 'WORKING'} /></label><label className="field"><span>Descripción / análisis técnico</span><textarea rows={8} maxLength={50000} value={reportDescription} onChange={(event) => setReportDescription(event.target.value)} disabled={!canManage || report.status !== 'WORKING'} /></label>{canManage && report.status === 'WORKING' && <div className="form-actions"><button className="button primary" disabled={working}>{working ? 'Guardando…' : 'Guardar contenido del INF'}</button></div>}{report.status !== 'WORKING' && <Alert type="info">El contenido editable se cerró al registrar manualmente el PDF oficial de GEDO.</Alert>}</form>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">INFORME Y RESPUESTAS</span><h2>Historia y presentaciones</h2><p className="section-copy">La historia del Acta y las respuestas posteriores se leen en orden. Las nuevas respuestas se registran en este Informe.</p></div></div>
|
||||
<div className="dossier-link-list">{timeline.map((item) => <div key={item.id}>
|
||||
<div><strong>{item.title}</strong><small>{item.description}</small>{item.href && (item.fileName ? <a className="text-link" href={item.href}>Descargar {item.fileName}</a> : <Link className="text-link" to={item.href}>Ver Acta</Link>)}</div>
|
||||
<span>{formatDate(item.date)}</span>
|
||||
</div>)}</div>
|
||||
|
||||
{canManage && <form className="form-section" onSubmit={addFollowUp}>
|
||||
<div><h3>Registrar respuesta o antecedente</h3><p className="section-copy">La respuesta queda asociada a este Informe y conserva los registros anteriores.</p></div>
|
||||
<div className="form-grid">
|
||||
<label className="field"><span>Tipo</span><SearchableSelect value={followUpType} onChange={(event) => setFollowUpType(event.target.value as InspectionReportFollowUpType)}><option value="COMPANY_NOTE">Presentación / nota de empresa</option><option value="COMPANY_DOCUMENT">Documento de empresa</option><option value="INTERNAL_NOTE">Nota interna</option><option value="VERIFICATION">Verificación</option><option value="OTHER">Otro antecedente</option></SearchableSelect></label>
|
||||
<label className="field"><span>Fecha</span><input type="datetime-local" value={followUpOccurredAt} onChange={(event) => setFollowUpOccurredAt(event.target.value)} required /></label>
|
||||
<label className="field"><span>Referencia externa <em>opcional</em></span><input value={followUpReference} onChange={(event) => setFollowUpReference(event.target.value)} maxLength={255} placeholder="GEDO, expediente, nota, ticket…" /></label>
|
||||
</div>
|
||||
<label className="field"><span>Descripción</span><textarea rows={4} maxLength={20000} value={followUpDescription} onChange={(event) => setFollowUpDescription(event.target.value)} placeholder="Contenido o resumen de la presentación…" /></label>
|
||||
<label className="field"><span>Archivo <em>opcional</em></span><input type="file" onChange={(event) => setFollowUpFile(event.target.files?.[0] ?? null)} /></label>
|
||||
<div className="form-actions"><button className="button primary" disabled={working || (!followUpDescription.trim() && !followUpReference.trim() && !followUpFile)}>{working ? 'Agregando…' : 'Agregar al historial'}</button></div>
|
||||
</form>}
|
||||
<div className="panel-heading"><div><span className="eyebrow">GEDO</span><h2>Oficialización del Informe</h2><p className="section-copy">No existe una respuesta automática de GEDO. Cuando recibas el identificador IF y el PDF oficial, cargalos manualmente aquí.</p></div></div>
|
||||
{report.status === 'OFFICIALIZED' ? <><div className="responsible-summary"><div><small>Identificador IF</small><strong>{report.gedoIfIdentifier ?? '—'}</strong></div><div><small>Fecha GEDO</small><strong>{formatDate(report.gedoOfficializedAt)}</strong></div><div><small>PDF oficial</small><strong>{report.gedoPdfOriginalName ?? 'Registrado'}</strong></div><div><small>Vencimiento de respuestas</small><strong>{formatDateOnly(report.responseDueOn)}</strong></div></div>{report.gedoPdfOriginalName && <div className="form-actions"><a className="button secondary" href={inspectionReportGedoPdfDownloadUrl(report.id)}>Descargar PDF oficial</a></div>}{report.gedoPdfSha256 && <div className="temporal-notice"><Icon name="check" /><p><strong>PDF GEDO fijado.</strong> SHA-256: <code>{report.gedoPdfSha256}</code></p></div>}</> : canManage && report.status === 'WORKING' ? <form className="form-section" onSubmit={officialize}><div className="form-grid"><label className="field"><span>Identificador IF de GEDO</span><input value={gedoIfIdentifier} onChange={(event) => setGedoIfIdentifier(event.target.value)} required maxLength={255} placeholder="IF-2026-…" /></label><label className="field"><span>Fecha de oficialización</span><input type="datetime-local" value={gedoOfficializedAt} onChange={(event) => setGedoOfficializedAt(event.target.value)} required /></label></div><label className="field"><span>PDF oficial de GEDO</span><input type="file" accept="application/pdf,.pdf" onChange={(event) => setGedoFile(event.target.files?.[0] ?? null)} required /></label><Alert>Esta carga es manual. Registra la referencia institucional del Informe, pero no crea respuestas ni vencimientos automáticamente.</Alert><div className="form-actions"><button className="button primary" disabled={working || !gedoFile || !gedoIfIdentifier.trim()}>{working ? 'Registrando…' : 'Cargar IF y PDF oficial'}</button></div></form> : <Alert type="info">El Informe todavía no está oficializado en GEDO.{report.status === 'WORKING' && !canManage ? ' Tu usuario no tiene permiso para gestionar u oficializar Informes.' : ''}</Alert>}
|
||||
</section>
|
||||
|
||||
<section className="panel report-integrity-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">INTEGRIDAD</span><h2>Fuente inmutable</h2><p className="section-copy">El INF conserva una copia verificable del Acta sellada que le dio origen.</p></div></div>
|
||||
<dl className="report-integrity-list">
|
||||
<div><dt>Acta fuente</dt><dd>{report.act.code}</dd></div>
|
||||
<div><dt>Hash de cierre del Acta</dt><dd>{report.actClosureSha256}</dd></div>
|
||||
<div><dt>Hash de la fuente del INF</dt><dd>{report.frozenSha256}</dd></div>
|
||||
<div><dt>Estado del INF</dt><dd>{reportStatusLabel(report.status)}</dd></div>
|
||||
</dl>
|
||||
<section className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">PLAZO DE RESPUESTA</span><h2>Vencimiento común del Acta</h2><p className="section-copy">La fecha se define una sola vez para el Acta {report.act.code} y se proyecta sobre todos sus Hallazgos.</p></div></div>
|
||||
{report.status !== 'OFFICIALIZED' ? <Alert type="info">Este paso se habilita después de cargar el PDF oficial de GEDO.</Alert> : <>
|
||||
<div className="responsible-summary"><div><small>Acta relacionada</small><strong>{report.act.code}</strong></div><div><small>Vencimiento vigente</small><strong>{formatDateOnly(report.responseDueOn)}</strong></div><div><small>Hallazgos alcanzados</small><strong>{report.findings.length}</strong></div><div><small>Motivo / referencia</small><strong>{report.deadlineReason ?? 'Sin definir'}</strong></div></div>
|
||||
{canManage && <form className="form-section" onSubmit={saveDeadline}><div className="form-grid"><label className="field"><span>Fecha de vencimiento</span><input type="date" value={responseDueOn} onChange={(event) => setResponseDueOn(event.target.value)} required /></label><label className="field"><span>Motivo / referencia <em>opcional</em></span><input value={deadlineReason} onChange={(event) => setDeadlineReason(event.target.value)} maxLength={1000} placeholder={`Vencimiento general del Acta ${report.act.code}`} /></label></div><div className="form-actions"><button className="button primary" disabled={working || !responseDueOn}>{report.responseDueOn ? 'Registrar nuevo vencimiento' : 'Definir vencimiento'}</button></div></form>}
|
||||
</>}
|
||||
<div className="table-scroll"><SortableTable><thead><tr><th>Hallazgo</th><th>Elemento</th><th>Estado</th><th>Vencimiento</th></tr></thead><tbody>{report.findings.map((finding) => <tr key={finding.id}><td><Link className="text-link" to={`/hallazgos/${finding.id}`}>{finding.code}</Link><small className="block-muted">{finding.title}</small></td><td><strong>{finding.assetName}</strong><small className="block-muted">{finding.assetCode}</small></td><td><span className={`status-badge ${finding.status === 'OPEN' ? 'observed' : 'active'}`}>{finding.status === 'OPEN' ? 'Abierto' : finding.status}</span></td><td><strong>{formatDateOnly(finding.responseDueOn)}</strong></td></tr>)}</tbody></SortableTable></div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">RESPUESTAS DE EMPRESA</span><h2>Presentaciones vinculadas al Acta {report.act.code}</h2><p className="section-copy">Cada respuesta queda vinculada simultáneamente a este Informe y a su Acta fuente. Puede incluir un PDF recibido de la empresa.</p></div></div>
|
||||
{report.status !== 'OFFICIALIZED' ? <Alert type="info">Las respuestas se habilitan después de cargar el PDF oficial de GEDO.</Alert> : <>
|
||||
{report.companyResponses.length === 0 ? <Alert type="info">Todavía no se registraron respuestas de la empresa.</Alert> : <div className="dossier-link-list">{report.companyResponses.map((item) => <div key={item.id}><div><strong>Respuesta recibida {formatDateOnly(item.receivedOn)}</strong><small>{item.details ?? 'Sin detalle'}{item.committedCorrectionOn ? ` · Compromiso: ${formatDateOnly(item.committedCorrectionOn)}` : ''}</small>{item.contactName && <small>{item.contactName}{item.contactEmail ? ` · ${item.contactEmail}` : ''}</small>}{item.originalName && <a className="text-link" href={inspectionReportCompanyResponseDownloadUrl(report.id, item.id)}>Descargar {item.originalName} {fileSize(item.sizeBytes) && `· ${fileSize(item.sizeBytes)}`}</a>}</div><span>Acta {report.act.code}</span></div>)}</div>}
|
||||
{canManage && <form className="form-section" onSubmit={addCompanyResponse}><div><h3>Registrar respuesta</h3><p className="section-copy">La respuesta se registra sobre el Informe {report.code} y queda relacionada con el Acta {report.act.code}.</p></div><div className="form-grid"><label className="field"><span>Fecha de recepción</span><input type="date" value={companyReceivedOn} onChange={(event) => setCompanyReceivedOn(event.target.value)} required /></label><label className="field"><span>Fecha comprometida por la empresa <em>opcional</em></span><input type="date" value={companyCommittedOn} onChange={(event) => setCompanyCommittedOn(event.target.value)} /></label><label className="field"><span>Contacto <em>opcional</em></span><input value={companyContactName} onChange={(event) => setCompanyContactName(event.target.value)} maxLength={200} /></label><label className="field"><span>Email <em>opcional</em></span><input type="email" value={companyContactEmail} onChange={(event) => setCompanyContactEmail(event.target.value)} maxLength={320} /></label></div><label className="field"><span>Detalle de la respuesta</span><textarea rows={5} maxLength={8000} value={companyDetails} onChange={(event) => setCompanyDetails(event.target.value)} placeholder="Respuesta, descargo, compromiso o documentación presentada…" /></label><label className="field"><span>PDF de respuesta <em>opcional</em></span><input type="file" accept="application/pdf,.pdf" onChange={(event) => setCompanyResponseFile(event.target.files?.[0] ?? null)} /></label><div className="form-actions"><button className="button primary" disabled={working || (!companyDetails.trim() && !companyResponseFile)}>Registrar respuesta</button></div></form>}
|
||||
</>}
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">HISTORIA DEL INFORME</span><h2>Otros antecedentes</h2><p className="section-copy">Cronología documental completa. Las respuestas formales de empresa se cargan en el bloque anterior; aquí quedan notas internas, verificaciones y otros antecedentes.</p></div></div>
|
||||
<div className="dossier-link-list">{timeline.map((item) => <div key={item.id}><div><strong>{item.title}</strong><small>{item.description}</small>{item.href && (item.fileName ? <a className="text-link" href={item.href}>Descargar {item.fileName}</a> : <Link className="text-link" to={item.href}>Ver Acta</Link>)}</div><span>{formatDate(item.date)}</span></div>)}</div>
|
||||
{canManage && <form className="form-section" onSubmit={addFollowUp}><div><h3>Agregar otro antecedente</h3><p className="section-copy">No usar este bloque para respuestas formales de empresa.</p></div><div className="form-grid"><label className="field"><span>Tipo</span><SearchableSelect value={followUpType} onChange={(event) => setFollowUpType(event.target.value as InspectionReportFollowUpType)}><option value="INTERNAL_NOTE">Nota interna</option><option value="VERIFICATION">Verificación</option><option value="OTHER">Otro antecedente</option></SearchableSelect></label><label className="field"><span>Fecha</span><input type="datetime-local" value={followUpOccurredAt} onChange={(event) => setFollowUpOccurredAt(event.target.value)} required /></label><label className="field"><span>Referencia externa <em>opcional</em></span><input value={followUpReference} onChange={(event) => setFollowUpReference(event.target.value)} maxLength={255} /></label></div><label className="field"><span>Descripción</span><textarea rows={4} maxLength={20000} value={followUpDescription} onChange={(event) => setFollowUpDescription(event.target.value)} /></label><label className="field"><span>Archivo <em>opcional</em></span><input type="file" onChange={(event) => setFollowUpFile(event.target.files?.[0] ?? null)} /></label><div className="form-actions"><button className="button primary" disabled={working || (!followUpDescription.trim() && !followUpReference.trim() && !followUpFile)}>Agregar al historial</button></div></form>}
|
||||
</section>
|
||||
|
||||
<section className="panel report-integrity-panel"><div className="panel-heading"><div><span className="eyebrow">INTEGRIDAD</span><h2>Fuente inmutable</h2><p className="section-copy">El INF conserva una copia verificable del Acta sellada que le dio origen.</p></div></div><dl className="report-integrity-list"><div><dt>Acta fuente</dt><dd>{report.act.code}</dd></div><div><dt>Hash de cierre del Acta</dt><dd>{report.actClosureSha256}</dd></div><div><dt>Hash de la fuente del INF</dt><dd>{report.frozenSha256}</dd></div><div><dt>Estado del INF</dt><dd>{reportStatusLabel(report.status)}</dd></div></dl></section>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../components/SortableTable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
@@ -147,7 +148,7 @@ export function ReportsPage() {
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label={view === 'issued' ? 'Cargando informes…' : 'Cargando actas pendientes…'} /> : empty ? <EmptyState title={view === 'issued' ? 'Sin informes' : 'Sin actas pendientes'} text={view === 'issued' ? 'Todavía no hay informes para los filtros seleccionados.' : 'Todas las Actas selladas tienen su INF correspondiente.'} /> : <div className="table-panel document-table">
|
||||
<div className="table-summary"><strong>{meta.total} {view === 'issued' ? `informe${meta.total === 1 ? '' : 's'}` : `acta${meta.total === 1 ? '' : 's'} pendiente${meta.total === 1 ? '' : 's'}`}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div>
|
||||
<div className="table-scroll"><table><thead>{view === 'issued' ? <tr><th>Informe</th><th>Empresa / área</th><th>Acta</th><th>Hallazgos</th><th>Word</th><th>Estado</th><th /></tr> : <tr><th>Acta sellada</th><th>Empresa / área</th><th>Inspección</th><th>Hallazgos</th><th>Sellado</th><th /></tr>}</thead><tbody>{view === 'issued' ? issued.map((report) => <tr key={report.id}>
|
||||
<div className="table-scroll"><SortableTable><thead>{view === 'issued' ? <tr><th>Informe</th><th>Empresa / área</th><th>Acta</th><th>Hallazgos</th><th>Word</th><th>Estado</th><th /></tr> : <tr><th>Acta sellada</th><th>Empresa / área</th><th>Inspección</th><th>Hallazgos</th><th>Sellado</th><th /></tr>}</thead><tbody>{view === 'issued' ? issued.map((report) => <tr key={report.id}>
|
||||
<td><div className="document-primary"><strong>{report.code}</strong><small>{report.title} · {formatDate(report.generatedAt)}</small></div></td>
|
||||
<td><div className="document-primary"><strong>{contextLabel(report.companies, 'Empresa sin asignar')}</strong><small>{contextLabel(report.areas, 'Área sin asignar')}</small></div></td>
|
||||
<td><Link className="text-link" to={`/inspecciones/actas/${report.actId}`}>{report.act.code}<small className="block-muted">{report.act.title}</small></Link></td>
|
||||
@@ -162,7 +163,7 @@ export function ReportsPage() {
|
||||
<td><Link className="text-link" to={`/hallazgos?search=${encodeURIComponent(item.actCode)}`}>{item.findingCount}</Link></td>
|
||||
<td>{formatDate(item.sealedAt)}</td>
|
||||
<td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${item.actId}`} aria-label={`Abrir ${item.actCode}`}><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>}
|
||||
</section>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../components/SortableTable';
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
@@ -286,7 +287,7 @@ export function SurveyCampaignEditorPage() {
|
||||
<div className="form-actions"><button className="button primary" disabled={busy || !targetAssetId}><Icon name="plus" />Agregar objetivo</button></div>
|
||||
</form>}
|
||||
|
||||
{campaign && <div className="table-panel survey-targets"><div className="table-summary"><strong>{campaign.targetCount} objetivo{campaign.targetCount === 1 ? '' : 's'}</strong><span>{campaign.completedCount} completados · {campaign.submittedCount} en revisión · {campaign.inProgressCount} en ejecución · {campaign.pendingCount} pendientes · {campaign.skippedCount} omitidos</span></div>{campaign.targets.length === 0 ? <EmptyState title="Sin objetivos" text="Agregá registros del inventario para completar la planificación." /> : <div className="table-scroll"><table><thead><tr><th>Registro</th><th>Estado</th><th>Responsable</th><th>Vencimiento</th><th>Instrucciones</th><th /></tr></thead><tbody>{campaign.targets.map((target) => {
|
||||
{campaign && <div className="table-panel survey-targets"><div className="table-summary"><strong>{campaign.targetCount} objetivo{campaign.targetCount === 1 ? '' : 's'}</strong><span>{campaign.completedCount} completados · {campaign.submittedCount} en revisión · {campaign.inProgressCount} en ejecución · {campaign.pendingCount} pendientes · {campaign.skippedCount} omitidos</span></div>{campaign.targets.length === 0 ? <EmptyState title="Sin objetivos" text="Agregá registros del inventario para completar la planificación." /> : <div className="table-scroll"><SortableTable><thead><tr><th>Registro</th><th>Estado</th><th>Responsable</th><th>Vencimiento</th><th>Instrucciones</th><th /></tr></thead><tbody>{campaign.targets.map((target) => {
|
||||
const canAct = canExecute && (canManage || target.assignedUser?.id === user?.id);
|
||||
const planLocked = target.status === 'SUBMITTED' || target.status === 'COMPLETED';
|
||||
const availableStatuses = campaign.status === 'IN_PROGRESS'
|
||||
@@ -304,6 +305,6 @@ export function SurveyCampaignEditorPage() {
|
||||
: [];
|
||||
const draft = drafts[target.id] ?? { dueAt: '', instructions: '' };
|
||||
return <tr key={target.id}><td><Link className="history-asset-link" to={`/inventarios/${target.asset.id}`}><strong>{target.asset.name}</strong><small>{target.asset.code} · {target.asset.typeName}</small></Link></td><td><span className={`status-badge ${surveyStatusClass(target.status)}`}>{surveyTargetStatusLabel(target.status)}</span>{canAct && availableStatuses.length > 0 && <SearchableSelect className="survey-inline-select" value="" disabled={busy} onChange={(event) => event.target.value && changeTargetStatus(target, event.target.value as SurveyTargetStatus)}><option value="">Cambiar…</option>{availableStatuses.map((status) => <option key={status} value={status}>{SURVEY_TARGET_STATUSES.find((item) => item.value === status)?.label}</option>)}</SearchableSelect>}</td><td>{canAssign && !closed && !planLocked ? <SearchableSelect className="survey-inline-select wide" value={target.assignedUser?.id ?? ''} disabled={busy} onChange={(event) => assignTarget(target, event.target.value)}><option value="">Sin asignar</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)}</option>)}</SearchableSelect> : target.assignedUser ? personName(target.assignedUser) : 'Sin asignar'}</td><td>{canManage && !closed && !planLocked ? <input className="survey-inline-input" type="datetime-local" value={draft.dueAt} onChange={(event) => setDrafts((current) => ({ ...current, [target.id]: { ...draft, dueAt: event.target.value } }))} /> : formatDate(target.dueAt)}</td><td>{canManage && !closed && !planLocked ? <input className="survey-inline-input instructions" value={draft.instructions} onChange={(event) => setDrafts((current) => ({ ...current, [target.id]: { ...draft, instructions: event.target.value } }))} maxLength={4000} placeholder="Sin instrucciones" /> : target.instructions ?? '—'}</td><td><div className="survey-row-actions">{canManage && !closed && !planLocked && <button type="button" className="icon-button" title="Guardar planificación del objetivo" disabled={busy} onClick={() => saveTargetPlan(target)}><Icon name="check" /></button>}{canReadReports && <Link className="icon-button" title="Abrir informe de campo" to={`/relevamiento/objetivos/${target.id}`}><Icon name="clipboard" /></Link>}</div></td></tr>;
|
||||
})}</tbody></table></div>}</div>}
|
||||
})}</tbody></SortableTable></div>}</div>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../components/SortableTable';
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
@@ -76,6 +77,6 @@ export function SurveyCampaignsPage() {
|
||||
</form>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label="Cargando campañas…" /> : campaigns.length === 0 ? <EmptyState title="Sin campañas" text="Creá una campaña para planificar el próximo relevamiento de los inventarios." /> : <div className="table-panel"><div className="table-summary"><strong>{meta.total} campaña{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>Campaña</th><th>Estado</th><th>Alcance</th><th>Coordinación</th><th>Fechas previstas</th><th>Avance</th><th /></tr></thead><tbody>{campaigns.map((campaign) => <tr key={campaign.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{campaign.name}</strong><small>{campaign.code}</small></div></div></td><td><span className={`status-badge ${surveyStatusClass(campaign.status)}`}>{surveyCampaignStatusLabel(campaign.status)}</span></td><td>{campaign.scopeAsset ? <Link className="text-link" to={`/inventarios/${campaign.scopeAsset.id}`}>{campaign.scopeAsset.name}</Link> : <span className="muted">Todos los inventarios</span>}</td><td>{campaign.coordinator ? `${campaign.coordinator.firstName} ${campaign.coordinator.lastName}` : 'Sin asignar'}</td><td><span className="survey-date-range">{formatDate(campaign.plannedStartAt)}<small>hasta {formatDate(campaign.plannedEndAt)}</small></span></td><td><div className="survey-progress"><strong>{campaign.completedCount + campaign.skippedCount}/{campaign.targetCount}</strong><span><i style={{ width: `${campaign.targetCount ? ((campaign.completedCount + campaign.skippedCount) / campaign.targetCount) * 100 : 0}%` }} /></span></div></td><td className="action-cell"><Link className="icon-button" to={`/relevamiento/${campaign.id}`} aria-label={`Abrir ${campaign.name}`}><Icon name="chevron" /></Link></td></tr>)}</tbody></table></div><div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
|
||||
{loading ? <LoadingBlock label="Cargando campañas…" /> : campaigns.length === 0 ? <EmptyState title="Sin campañas" text="Creá una campaña para planificar el próximo relevamiento de los inventarios." /> : <div className="table-panel"><div className="table-summary"><strong>{meta.total} campaña{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><SortableTable><thead><tr><th>Campaña</th><th>Estado</th><th>Alcance</th><th>Coordinación</th><th>Fechas previstas</th><th>Avance</th><th /></tr></thead><tbody>{campaigns.map((campaign) => <tr key={campaign.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{campaign.name}</strong><small>{campaign.code}</small></div></div></td><td><span className={`status-badge ${surveyStatusClass(campaign.status)}`}>{surveyCampaignStatusLabel(campaign.status)}</span></td><td>{campaign.scopeAsset ? <Link className="text-link" to={`/inventarios/${campaign.scopeAsset.id}`}>{campaign.scopeAsset.name}</Link> : <span className="muted">Todos los inventarios</span>}</td><td>{campaign.coordinator ? `${campaign.coordinator.firstName} ${campaign.coordinator.lastName}` : 'Sin asignar'}</td><td><span className="survey-date-range">{formatDate(campaign.plannedStartAt)}<small>hasta {formatDate(campaign.plannedEndAt)}</small></span></td><td><div className="survey-progress"><strong>{campaign.completedCount + campaign.skippedCount}/{campaign.targetCount}</strong><span><i style={{ width: `${campaign.targetCount ? ((campaign.completedCount + campaign.skippedCount) / campaign.targetCount) * 100 : 0}%` }} /></span></div></td><td className="action-cell"><Link className="icon-button" to={`/relevamiento/${campaign.id}`} aria-label={`Abrir ${campaign.name}`}><Icon name="chevron" /></Link></td></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>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../components/SortableTable';
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
@@ -89,7 +90,7 @@ export function TemporalAssetsPage() {
|
||||
|
||||
<div className="temporal-result-heading"><strong>Inventarios reconstruidos al {formatDate(at)}</strong><span>Las vigencias terminan cuando se registra la versión siguiente.</span></div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label="Reconstruyendo inventarios…" /> : assets.length === 0 ? <EmptyState title="Sin registros en esa fecha" text="No existen versiones históricas que coincidan con la fecha y los filtros seleccionados." /> : <div className="table-panel temporal-table"><div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'} reconstruido{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>Registro histórico</th><th>Tipo</th><th>Estado</th><th>Versión aplicable</th><th>Vigente desde</th><th>Vigente hasta</th><th>Cambio</th><th /></tr></thead><tbody>{assets.map((asset) => <tr key={asset.assetId}><td><Link className="history-asset-link" to={`/inventarios/${asset.assetId}`}><strong>{asset.assetName}</strong><small>{asset.assetCode}</small></Link></td><td><span className="tag">{asset.typeName}</span></td><td>{assetStatusLabel(asset.informationStatus)}</td><td><span className={`version-badge ${asset.isCurrent ? 'current' : ''}`}>v{asset.versionNumber}{asset.isCurrent ? ' · actual' : ''}</span></td><td>{formatDate(asset.occurredAt)}</td><td>{asset.effectiveUntil ? formatDate(asset.effectiveUntil) : 'Continúa vigente'}</td><td>{assetVersionChangeLabel(asset.changeType)}</td><td><button type="button" className="icon-button" onClick={() => open(asset)} aria-label={`Ver versión ${asset.versionNumber}`}><Icon name="chevron" /></button></td></tr>)}</tbody></table></div><div className="pagination"><button type="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 type="button" className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
|
||||
{loading ? <LoadingBlock label="Reconstruyendo inventarios…" /> : assets.length === 0 ? <EmptyState title="Sin registros en esa fecha" text="No existen versiones históricas que coincidan con la fecha y los filtros seleccionados." /> : <div className="table-panel temporal-table"><div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'} reconstruido{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><SortableTable><thead><tr><th>Registro histórico</th><th>Tipo</th><th>Estado</th><th>Versión aplicable</th><th>Vigente desde</th><th>Vigente hasta</th><th>Cambio</th><th /></tr></thead><tbody>{assets.map((asset) => <tr key={asset.assetId}><td><Link className="history-asset-link" to={`/inventarios/${asset.assetId}`}><strong>{asset.assetName}</strong><small>{asset.assetCode}</small></Link></td><td><span className="tag">{asset.typeName}</span></td><td>{assetStatusLabel(asset.informationStatus)}</td><td><span className={`version-badge ${asset.isCurrent ? 'current' : ''}`}>v{asset.versionNumber}{asset.isCurrent ? ' · actual' : ''}</span></td><td>{formatDate(asset.occurredAt)}</td><td>{asset.effectiveUntil ? formatDate(asset.effectiveUntil) : 'Continúa vigente'}</td><td>{assetVersionChangeLabel(asset.changeType)}</td><td><button type="button" className="icon-button" onClick={() => open(asset)} aria-label={`Ver versión ${asset.versionNumber}`}><Icon name="chevron" /></button></td></tr>)}</tbody></SortableTable></div><div className="pagination"><button type="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 type="button" className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
|
||||
{(detailLoading || detail) && <AssetVersionDrawer detail={detail} loading={detailLoading} onClose={() => setDetail(null)} />}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../components/SortableTable';
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
@@ -63,9 +64,9 @@ export function UsersPage() {
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label="Cargando usuarios…" /> : users.length === 0 ? <EmptyState title="Sin resultados" text="No encontramos usuarios con los filtros seleccionados." /> : <div className="table-panel">
|
||||
<div className="table-summary"><strong>{meta.total} usuario{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>Usuario</th><th>Roles</th><th>Estado</th><th>Último acceso</th><th aria-label="Acciones" /></tr></thead><tbody>
|
||||
<div className="table-scroll"><SortableTable><thead><tr><th>Usuario</th><th>Roles</th><th>Estado</th><th>Último acceso</th><th aria-label="Acciones" /></tr></thead><tbody>
|
||||
{users.map((user) => <tr key={user.id}><td><div className="person-cell"><span className="mini-avatar">{initials(user.firstName, user.lastName)}</span><div><strong>{user.firstName} {user.lastName}</strong><small>@{user.username}{user.email ? ` · ${user.email}` : ''}</small></div></div></td><td><div className="tag-list">{user.roles.length ? user.roles.map((role) => <span className="tag" key={role.id}>{role.name}</span>) : <span className="muted">Sin rol</span>}</div></td><td><span className={`status-badge ${user.status.toLowerCase()}`}>{user.status === 'ACTIVE' ? 'Activo' : 'Inactivo'}</span>{user.mustChangePassword && <span className="inline-note">Clave temporal</span>}</td><td>{formatDate(user.lastLoginAt)}</td><td className="action-cell"><Link className="icon-button" to={`/admin/users/${user.id}`} aria-label={`Abrir ${user.username}`}><Icon name="chevron" /></Link></td></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>}
|
||||
</section>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SortableTable } from '../components/SortableTable';
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
@@ -207,7 +208,7 @@ export function VerificationPlanningPage() {
|
||||
{success && <Alert type="success">{success}</Alert>}
|
||||
{loading ? <LoadingBlock label="Cargando verificaciones…" /> : items.length === 0 ? <EmptyState title="Sin verificaciones en esta vista" text="No hay Hallazgos abiertos con fecha de control que coincidan con el filtro seleccionado." /> : <div className="table-panel verification-planning-table">
|
||||
<div className="table-summary"><strong>{meta.total} hallazgo{meta.total === 1 ? '' : 's'}</strong><span>Seleccioná únicamente registros de una misma empresa y área</span></div>
|
||||
<div className="table-scroll"><table><thead><tr><th /><th>Hallazgo</th><th>Empresa / área</th><th>Elemento</th><th>Fecha objetivo</th><th>Planificación</th><th /></tr></thead><tbody>{items.map((item) => {
|
||||
<div className="table-scroll"><SortableTable><thead><tr><th /><th>Hallazgo</th><th>Empresa / área</th><th>Elemento</th><th>Fecha objetivo</th><th>Planificación</th><th /></tr></thead><tbody>{items.map((item) => {
|
||||
const key = groupKey(item);
|
||||
const blockedByGroup = Boolean(selectedGroup && selectedGroup !== key);
|
||||
const selectable = !item.verificationVisit && Boolean(item.company && item.area) && !blockedByGroup;
|
||||
@@ -221,7 +222,7 @@ export function VerificationPlanningPage() {
|
||||
<td>{item.verificationVisit ? <Link className="text-link" to={`/inspecciones/${item.verificationVisit.id}`}><strong>{item.verificationVisit.code}</strong><small className="block-muted">{formatDate(item.verificationVisit.plannedStartAt)} · {inspectionVisitStatusLabel(item.verificationVisit.status)}</small></Link> : item.company && item.area ? <span className="status-badge pending">Sin inspección</span> : <span className="status-badge danger">Falta contexto</span>}</td>
|
||||
<td className="action-cell"><Link className="icon-button" to={`/hallazgos/${item.id}`} aria-label={`Abrir ${item.code}`}><Icon name="chevron" /></Link></td>
|
||||
</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>}
|
||||
|
||||
|
||||
@@ -1436,3 +1436,38 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; }
|
||||
|
||||
.act-other-asset-photos { border-top: 1px solid #e3eaf5; margin-top: 24px; padding-top: 20px; }
|
||||
.act-other-asset-photos h3 { margin: 0 0 14px; }
|
||||
|
||||
/* F6.15 · capas del mapa operativo */
|
||||
.map-layer-buttons { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.map-layer-buttons .button { flex: 1 1 calc(50% - 6px); justify-content: center; min-width: 104px; }
|
||||
.map-global-search { width: 100%; margin: 0; }
|
||||
.act-finding-context { margin: 10px 0 12px; }
|
||||
|
||||
/* F6.16 · Mapa GPS puntual */
|
||||
.map-legend { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; margin: 0 0 12px; padding: 10px 13px; border: 1px solid var(--line); border-radius: 10px; background: white; }
|
||||
.map-legend > span { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 9px; font-weight: 750; }
|
||||
.map-legend-dot { width: 9px; height: 9px; display: inline-block; flex: 0 0 auto; border: 2px solid white; border-radius: 50%; box-shadow: 0 0 0 1px rgba(15,23,42,.16); }
|
||||
.map-gps-marker { width: 18px; height: 18px; display: block; padding: 0; border: 3px solid white; border-radius: 50%; cursor: pointer; box-shadow: 0 2px 8px rgba(15,23,42,.28); transition: transform .15s ease, box-shadow .15s ease; }
|
||||
.map-gps-marker:hover { transform: scale(1.2); box-shadow: 0 3px 12px rgba(15,23,42,.35); }
|
||||
.map-gps-marker.selected { transform: scale(1.35); box-shadow: 0 0 0 3px rgba(245,158,11,.42), 0 4px 14px rgba(15,23,42,.32); }
|
||||
.kind-asset { background: #64748b; }
|
||||
.kind-yacimiento { background: #2563eb; }
|
||||
.kind-company { background: #059669; }
|
||||
.kind-act { background: #7c3aed; }
|
||||
.kind-finding { background: #dc2626; }
|
||||
.operational-map-stage { min-width: 0; position: relative; }
|
||||
.operational-map-stage .map-canvas { height: 650px; }
|
||||
.map-selection-overlay { position: absolute; left: 16px; bottom: 16px; z-index: 4; width: min(330px, calc(100% - 32px)); max-height: 52%; overflow-y: auto; margin: 0; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: rgba(255,255,255,.96); box-shadow: var(--shadow); backdrop-filter: blur(5px); }
|
||||
@media (max-width: 780px) { .operational-map-stage .map-canvas { height: 520px; } .map-selection-overlay { max-height: 62%; } }
|
||||
|
||||
/* F6.17 · Ordenamiento consistente de tablas */
|
||||
.sortable-th { padding: 0 !important; }
|
||||
.sortable-th-button {
|
||||
width: 100%; min-height: 38px; display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 7px; padding: 10px 12px; border: 0; color: inherit; background: transparent;
|
||||
font: inherit; font-weight: inherit; letter-spacing: inherit; text-transform: inherit; text-align: left; cursor: pointer;
|
||||
}
|
||||
.sortable-th-button:hover, .sortable-th-button:focus-visible { color: var(--blue); background: #f4f7ff; outline: none; }
|
||||
.sort-indicator { flex: 0 0 auto; color: #9aa6bb; font-size: 9px; line-height: 1; opacity: .55; }
|
||||
.sortable-th.is-sorted .sort-indicator { color: var(--blue); opacity: 1; }
|
||||
.sortable-th[aria-sort='ascending'], .sortable-th[aria-sort='descending'] { color: var(--blue); background: #f7f9ff; }
|
||||
|
||||
Reference in New Issue
Block a user