Files
Antifraude.Net/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Entrada.razor
Pedro 2b4678c26d - Sustituidas las confirmaciones genéricas del navegador por una ventana propia, responsive y accesible.
- Adaptados los avisos al importar, tramitar, rechazar y cambiar la asignación de denuncias de otros usuarios o grupos.
- Añadidos textos y botones específicos para cada operación.
- Actualizadas las instrucciones, sustituyendo “modal” por “ventana de configuración de la subida”.
- Aclarado el funcionamiento del asunto, grupo de destino y tercero.
2026-07-30 12:35:50 +02:00

1540 lines
57 KiB
Plaintext

@page "/Entrada"
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@attribute [Authorize]
@implements IAsyncDisposable
@using System.Globalization
@using System.Text.RegularExpressions
@using GestionaDenunciasAN.Models
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject ApiDenunciasClient ApiDenuncias
@inject IJSRuntime JSRuntime
@inject UiBusyService Busy
@inject UiDialogService Dialogs
<PageTitle>Entrada de denuncias</PageTitle>
<style>
.report-detail-overlay {
position: fixed;
inset: 0;
z-index: 4500;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(1rem, 3vw, 2rem);
background: rgba(6, 22, 41, 0.62);
overflow: hidden;
}
.report-detail-dialog {
width: min(820px, 100%);
max-height: min(88vh, 860px);
display: flex;
}
.report-detail-card {
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(215, 228, 241, 0.92);
border-radius: 22px;
background: #ffffff;
box-shadow: 0 28px 90px rgba(6, 22, 41, 0.38);
color: var(--app-ink, #12395f);
}
.report-detail-header,
.report-detail-footer {
flex: 0 0 auto;
background: #ffffff;
}
.report-detail-header {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 1.35rem 1rem;
border-bottom: 1px solid var(--app-border, #d7e4f1);
}
.report-detail-body {
flex: 1 1 auto;
overflow-y: auto;
padding: 1rem 1.35rem;
background: #ffffff;
}
.report-detail-footer {
display: flex;
justify-content: flex-end;
padding: 1rem 1.35rem;
border-top: 1px solid var(--app-border, #d7e4f1);
}
.report-detail-close {
flex: 0 0 auto;
}
.report-row-disabled {
opacity: 0.62;
}
.report-row-disabled td {
cursor: not-allowed;
}
.inbox-table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.inbox-table {
min-width: 1080px;
font-size: 0.84rem;
white-space: nowrap;
}
.inbox-table th,
.inbox-table td {
vertical-align: middle;
padding: 0.45rem 0.5rem;
}
.inbox-report-cell {
width: 4.5rem;
}
.inbox-channel-cell {
width: 10.5rem;
max-width: 10.5rem;
}
.inbox-channel-cell .text-truncate {
max-width: 100%;
}
.inbox-tracking-cell {
width: 11rem;
max-width: 11rem;
overflow: hidden;
text-overflow: ellipsis;
}
.inbox-activity-cell {
width: 9.25rem;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
}
.inbox-action-cell {
width: 5.5rem;
text-align: right;
}
.inbox-detail-button {
padding-inline: 0.55rem;
white-space: nowrap;
}
@@media (max-width: 768px) {
.inbox-table {
min-width: 1020px;
font-size: 0.8rem;
}
.inbox-table th,
.inbox-table td {
padding: 0.4rem 0.42rem;
}
}
</style>
<div class="container-fluid py-4 px-3 px-xl-4">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
<div>
<h3 class="mb-1">Entrada de denuncias</h3>
<p class="text-muted mb-0">
La app mantiene su propia sesion activa. Cuando caduque GlobalLeaks, aqui solo hara falta renovar el 2FA
para seguir trayendo denuncias.
</p>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(StatusMessage))
{
<div class="alert @StatusCss mb-4">@StatusMessage</div>
}
@if (!string.IsNullOrWhiteSpace(FilterWarningMessage))
{
<div class="alert @FilterWarningCss mb-4">@FilterWarningMessage</div>
}
<div class="card shadow-sm mb-3">
<div class="card-body py-3">
<div class="row g-3 align-items-end">
<div class="col-xl-5 col-lg-6">
<h5 class="card-title mb-2">Sesion GlobalLeaks</h5>
<div class="d-flex flex-wrap gap-3 small">
<span><strong>Usuario:</strong> @CurrentUsername</span>
<span><strong>Estado:</strong> @SessionStatusText</span>
<span>
<strong>Ultima descarga:</strong>
@(UserInboxState.LastDownloadedReportMomentUtc is null
? "Sin descargas previas"
: UserInboxState.LastDownloadedReportMomentUtc.Value.ToLocalTime().ToString("dd/MM/yyyy HH:mm"))
</span>
</div>
</div>
<div class="col-xl-5 col-lg-4">
<label class="form-label mb-1">Nuevo codigo 2FA</label>
<input class="form-control"
@bind="RenewAuthcode"
maxlength="6"
inputmode="numeric"
disabled="@(!RenewPrepared || RenewBusy)"
placeholder="123456" />
</div>
<div class="col-xl-2 col-lg-2">
<button type="button" class="btn btn-primary w-100" @onclick="RenewSessionAsync" disabled="@RenewBusy">
@(RenewBusy ? SessionRenewBusyText : SessionRenewButtonText)
</button>
</div>
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-3">
<div>
<h5 class="card-title mb-1">Bandeja GlobalLeaks</h5>
<p class="text-muted mb-0">
Puedes revisar solo lo nuevo, las actualizaciones desde tu ultima descarga o un intervalo de fechas.
</p>
</div>
<button type="button" class="btn btn-outline-primary" @onclick="LoadReportsAsync" disabled="@ReportsBusy || !CanUseGlobalLeaks">
@(ReportsBusy ? "Actualizando..." : "Actualizar denuncias")
</button>
</div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label">Tipo</label>
<select class="form-select" value="@Filter" @onchange="OnFilterChanged">
<option value="all">Todas</option>
<option value="new">Nuevas / sin leer</option>
<option value="updated">Actualizaciones del ciudadano</option>
<option value="receiver">Actividad OAAF</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Periodo</label>
<select class="form-select" value="@DateScope" @onchange="OnDateScopeChanged">
<option value="all">Todo el buzon</option>
<option value="since_mine">Desde mi ultima descarga</option>
<option value="range">Intervalo de fechas</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Canal</label>
<select class="form-select" value="@SelectedChannel" @onchange="OnChannelChanged">
<option value="">Todos</option>
@foreach (var context in Contexts)
{
<option value="@context.Id">@context.Name</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Desde</label>
<input class="form-control"
type="date"
value="@DateFrom"
disabled="@(DateScope != "range")"
@onchange="OnDateFromChanged" />
</div>
<div class="col-md-3">
<label class="form-label">Hasta</label>
<input class="form-control"
type="date"
value="@DateTo"
disabled="@(DateScope != "range")"
@onchange="OnDateToChanged" />
</div>
<div class="col-md-6">
<label class="form-label">Buscar</label>
<input class="form-control"
value="@SearchTerm"
@oninput="OnSearchChanged"
placeholder="# denuncia o canal" />
</div>
</div>
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-3">
<div class="text-muted">
@VisibleReports.Count denuncia(s) visibles, @SelectedReportsCount seleccionada(s).
@if (VisibleReports.Any(report => report.Accessible == false))
{
<span class="d-block small text-danger">
@VisibleReports.Count(report => report.Accessible == false) denuncia(s) marcadas por GlobalLeaks como no accesibles para este usuario.
</span>
}
@if (UserInboxState.LastDownloadedReportMomentUtc is not null)
{
<span class="d-block small">
Referencia de ultima descarga: @UserInboxState.LastDownloadedReportMomentUtc.Value.ToLocalTime().ToString("dd/MM/yyyy HH:mm")
</span>
}
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="ToggleSelectAll" disabled="@(!VisibleReports.Any(CanUseReport))">
@SelectAllLabel
</button>
<button type="button" class="btn btn-success btn-sm" @onclick="ImportSelectedAsync" disabled="@(!CanImportSelected)">
@(ImportBusy ? "Importando..." : "Importar seleccionadas")
</button>
</div>
</div>
<div class="table-responsive inbox-table-wrap">
<table class="table table-hover table-sm align-middle inbox-table">
<thead>
<tr>
<th style="width: 3rem;"></th>
<th>#</th>
<th>Canal</th>
<th>Presentacion</th>
<th title="Fecha de la última aportación o comentario realizado por el ciudadano.">Actividad ciudadano</th>
<th title="Fecha de la última comunicación o fichero incorporado por un gestor de la OAAF.">Actividad OAAF</th>
<th title="Resume si la denuncia es nueva, tiene cambios pendientes o ya está actualizada en Gestiona.">Estado</th>
<th title="Indica si tu usuario gestor del buzón puede acceder a esta denuncia.">Acceso</th>
<th title="Indica si el cambio está pendiente, descargado en la aplicación o ya incorporado a Gestiona.">Gestiona</th>
<th class="inbox-action-cell" title="Abre el detalle disponible en GlobalLeaks.">Detalle</th>
</tr>
</thead>
<tbody>
@if (!CanUseGlobalLeaks)
{
<tr>
<td colspan="10" class="text-muted">
Renueva la sesion de GlobalLeaks con un 2FA valido para cargar la bandeja.
</td>
</tr>
}
else if (ReportsBusy)
{
<tr>
<td colspan="10" class="text-muted">Cargando denuncias...</td>
</tr>
}
else if (!VisibleReports.Any())
{
<tr>
<td colspan="10" class="text-muted">No hay denuncias con los filtros actuales.</td>
</tr>
}
else
{
@foreach (var report in VisibleReports)
{
<tr @key="report.Id" class="@GetReportRowCss(report)">
<td>
<input type="checkbox"
checked="@SelectedIds.Contains(report.Id)"
disabled="@(!CanUseReport(report) || ImportBusy)"
title="@GetReportActionBlockReason(report)"
@onchange="@((ChangeEventArgs args) => ToggleSelection(report, args))" />
</td>
<td class="inbox-report-cell"><strong>#@(report.Progressive ?? 0)</strong></td>
<td class="inbox-channel-cell" title="@(report.ContextName ?? report.ContextId ?? string.Empty)"><span class="d-inline-block text-truncate">@(report.ContextName ?? report.ContextId ?? "-")</span></td>
<td>@FormatDate(report.CreationDate)</td>
<td class="inbox-activity-cell">@FormatCitizenActivity(report)</td>
<td class="inbox-activity-cell" title="@GetReceiverActivityTitle(report)">@FormatReceiverActivity(report)</td>
<td>
<span class="badge @GetStatusBadgeCss(report)" title="@GetStatusHelp(report)">
@GetStatusLabel(report)
</span>
</td>
<td>
<span class="badge @GetAccessBadgeCss(report)" title="@GetAccessHelp(report)">
@GetAccessLabel(report)
</span>
</td>
<td class="inbox-tracking-cell">
<span class="badge @GetTrackingBadgeCss(report)" title="@GetTrackingHelp(report)">@GetTrackingLabel(report)</span>
</td>
<td class="inbox-action-cell">
<button type="button"
class="btn btn-outline-secondary btn-sm inbox-detail-button"
title="@GetReportDetailTitle(report)"
@onclick="@(() => OpenReportDetailAsync(report))"
disabled="@(DetailBusy || report.Accessible == false)">
Detalle
</button>
</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
</div>
</div>
@if (DetailModalVisible)
{
<div class="report-detail-overlay" role="dialog" aria-modal="true" aria-labelledby="report-detail-title">
<div class="report-detail-dialog">
<div class="report-detail-card">
<div class="report-detail-header">
<div>
<h5 id="report-detail-title" class="mb-0">Contenido de denuncia #@(DetailReport?.Progressive ?? 0)</h5>
<small class="text-muted">Mensajes y ficheros comparados contra el ultimo acceso registrado.</small>
</div>
<button type="button" class="btn-close report-detail-close" aria-label="Cerrar" @onclick="CloseReportDetail"></button>
</div>
<div class="report-detail-body">
<div class="alert alert-warning small">
Esta consulta abre el detalle en GlobalLeaks y puede marcar la denuncia como leida.
</div>
@if (DetailBusy)
{
<div class="text-muted">Cargando detalle de GlobalLeaks...</div>
}
else if (!string.IsNullOrWhiteSpace(DetailError))
{
<div class="alert alert-danger">@DetailError</div>
}
else if (DetailData is not null)
{
<p class="small text-muted">
Ultimo acceso previo:
@(string.IsNullOrWhiteSpace(DetailData.LastAccess) ? "Sin acceso previo" : FormatDate(DetailData.LastAccess)).
Los elementos posteriores se marcan como nuevos.
</p>
<h6 class="text-uppercase text-muted small fw-bold mt-4">Mensajes (@DetailData.Comments.Count)</h6>
@if (DetailData.Comments.Count == 0)
{
<p class="text-muted small">Sin mensajes.</p>
}
else
{
@foreach (var comment in DetailData.Comments)
{
<div class="border rounded p-3 mb-2 @(comment.IsNew ? "border-success bg-success-subtle" : "bg-light")">
<div class="d-flex flex-column flex-md-row justify-content-between gap-2 small text-muted mb-2">
<strong>@GetCommentAuthorLabel(comment)</strong>
<span>@FormatDate(comment.CreationDate)</span>
</div>
@if (comment.IsNew)
{
<span class="badge bg-success mb-2">Nuevo</span>
}
<div style="white-space: pre-wrap;">@(string.IsNullOrWhiteSpace(comment.Content) ? "-" : comment.Content)</div>
</div>
}
}
<h6 class="text-uppercase text-muted small fw-bold mt-4">Ficheros del denunciante (@DetailData.WhistleblowerFiles.Count)</h6>
@if (DetailData.WhistleblowerFiles.Count == 0)
{
<p class="text-muted small">Sin ficheros.</p>
}
else
{
@foreach (var file in DetailData.WhistleblowerFiles)
{
<div class="border rounded p-3 mb-2 @(file.IsNew ? "border-success bg-success-subtle" : "bg-light")">
<div class="d-flex flex-column flex-md-row justify-content-between gap-2">
<strong>@(string.IsNullOrWhiteSpace(file.Name) ? "Fichero sin nombre" : file.Name)</strong>
<span class="small text-muted">@FormatDate(file.CreationDate)</span>
</div>
<div class="small text-muted">@FormatBytes(file.Size) @(string.IsNullOrWhiteSpace(file.ContentType) ? string.Empty : $" - {file.ContentType}")</div>
@if (file.IsNew)
{
<span class="badge bg-success mt-2">Nuevo</span>
}
</div>
}
}
<h6 class="text-uppercase text-muted small fw-bold mt-4">Ficheros internos/receptor (@DetailData.ReceiverFiles.Count)</h6>
@if (DetailData.ReceiverFiles.Count == 0)
{
<p class="text-muted small">Sin ficheros.</p>
}
else
{
@foreach (var file in DetailData.ReceiverFiles)
{
<div class="border rounded p-3 mb-2 @(file.IsNew ? "border-success bg-success-subtle" : "bg-light")">
<div class="d-flex flex-column flex-md-row justify-content-between gap-2">
<strong>@(string.IsNullOrWhiteSpace(file.Name) ? "Fichero sin nombre" : file.Name)</strong>
<span class="small text-muted">@FormatDate(file.CreationDate)</span>
</div>
<div class="small text-muted">@FormatBytes(file.Size) @(string.IsNullOrWhiteSpace(file.ContentType) ? string.Empty : $" - {file.ContentType}")</div>
@if (!string.IsNullOrWhiteSpace(file.AuthorName))
{
<div class="small text-muted">Añadido por @file.AuthorName</div>
}
@if (file.IsNew)
{
<span class="badge bg-success mt-2">Nuevo</span>
}
</div>
}
}
}
</div>
<div class="report-detail-footer">
<button type="button" class="btn btn-secondary" @onclick="CloseReportDetail">Cerrar</button>
</div>
</div>
</div>
</div>
}
@code {
private readonly List<ContextDto> Contexts = [];
private readonly List<ReportDto> Reports = [];
private List<ReportDto> VisibleReports = [];
private HashSet<string> SelectedIds = [];
private ApiGlobalLeaksSessionDto? SessionInfo;
private InboxUserState UserInboxState = new();
private string CurrentUsername { get; set; } = string.Empty;
private string RenewAuthcode { get; set; } = string.Empty;
private string RenewPendingLoginId { get; set; } = string.Empty;
private string Filter { get; set; } = "all";
private string DateScope { get; set; } = "all";
private string DateFrom { get; set; } = string.Empty;
private string DateTo { get; set; } = string.Empty;
private string SelectedChannel { get; set; } = string.Empty;
private string SearchTerm { get; set; } = string.Empty;
private string StatusMessage { get; set; } = string.Empty;
private string StatusCss { get; set; } = "alert-info";
private string FilterWarningMessage { get; set; } = string.Empty;
private string FilterWarningCss { get; set; } = "alert-warning";
private bool ReportsBusy { get; set; }
private bool RenewBusy { get; set; }
private bool ImportBusy { get; set; }
private bool DetailBusy { get; set; }
private bool DetailModalVisible { get; set; }
private string DetailError { get; set; } = string.Empty;
private ReportDto? DetailReport { get; set; }
private ReportDetailDto? DetailData { get; set; }
private readonly Dictionary<string, ReportDetailDto> DetailCache = new(StringComparer.OrdinalIgnoreCase);
private bool CanUseGlobalLeaks => SessionInfo?.HasActiveSession == true;
private bool RenewPrepared => !string.IsNullOrWhiteSpace(RenewPendingLoginId);
private int SelectedReportsCount => Reports.Count(report => SelectedIds.Contains(report.Id) && CanUseReport(report));
private bool CanImportSelected => CanUseGlobalLeaks && SelectedReportsCount > 0 && !ImportBusy;
private string SessionStatusText => SessionInfo is null
? "Sin credenciales guardadas"
: SessionInfo.HasActiveSession
? "Activa"
: "Pendiente de renovacion 2FA";
private string SessionRenewButtonText => RenewPrepared
? "Validar 2FA y activar"
: "Preparar renovacion";
private string SessionRenewBusyText => RenewPrepared
? "Validando 2FA..."
: "Preparando...";
private string SelectAllLabel => VisibleReports.Any(CanUseReport) && VisibleReports.Where(CanUseReport).All(report => SelectedIds.Contains(report.Id))
? "Deseleccionar todas"
: "Seleccionar todas";
protected override async Task OnInitializedAsync()
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
CurrentUsername = authState.User.Identity?.Name ?? string.Empty;
await LoadSessionStateAsync();
if (CanUseGlobalLeaks)
{
await LoadReportsAsync();
}
else if (SessionInfo is not null)
{
SetStatus(
"La aplicacion sigue iniciada, pero la sesion de GlobalLeaks no esta activa. Introduce un nuevo 2FA para renovarla.",
"alert-warning");
}
}
private async Task LoadSessionStateAsync()
{
SessionInfo = string.IsNullOrWhiteSpace(CurrentUsername)
? null
: await ApiDenuncias.GetGlobalLeaksSessionAsync();
if (CanUseGlobalLeaks)
{
RenewPendingLoginId = string.Empty;
RenewAuthcode = string.Empty;
}
}
private async Task RenewSessionAsync()
{
if (SessionInfo is null)
{
SetStatus("No hay credenciales guardadas para este usuario. Cierra sesion y vuelve a entrar.", "alert-danger");
return;
}
if (!RenewPrepared)
{
await PrepareRenewSessionAsync();
return;
}
if (string.IsNullOrWhiteSpace(RenewAuthcode) || RenewAuthcode.Trim().Length != 6)
{
SetStatus("Introduce un codigo 2FA valido de 6 digitos.", "alert-warning");
return;
}
RenewBusy = true;
try
{
using var busy = Busy.Show(
"Validando 2FA",
"Completando la renovacion con el codigo actual de GlobalLeaks.");
SessionInfo = await ApiDenuncias.RenewGlobalLeaksSessionAsync(
RenewAuthcode.Trim(),
RenewPendingLoginId,
CancellationToken.None);
RenewAuthcode = string.Empty;
RenewPendingLoginId = string.Empty;
Busy.Update(message: "Sesion renovada. Actualizando la bandeja de entrada.");
await LoadReportsAsync();
SetStatus("Sesion de GlobalLeaks renovada correctamente.", "alert-success");
}
catch (Exception ex)
{
SetStatus(ex.Message, "alert-danger");
}
finally
{
RenewBusy = false;
}
}
private async Task PrepareRenewSessionAsync()
{
RenewBusy = true;
try
{
using var busy = Busy.Show(
"Preparando renovacion",
"Validando credenciales guardadas y preparando GlobalLeaks antes de pedir el codigo 2FA.");
var prepared = await ApiDenuncias.PrepareGlobalLeaksSessionRenewalAsync(CancellationToken.None);
RenewPendingLoginId = prepared.PendingLoginId;
RenewAuthcode = string.Empty;
SetStatus("Renovacion preparada. Introduce ahora el codigo 2FA que este activo.", "alert-info");
}
catch (Exception ex)
{
SetStatus(ex.Message, "alert-danger");
}
finally
{
RenewBusy = false;
}
}
private async Task LoadReportsAsync()
{
if (!CanUseGlobalLeaks)
{
return;
}
ReportsBusy = true;
try
{
using var busy = Busy.Show(
"Actualizando bandeja",
"Consultando GlobalLeaks y cruzando el seguimiento con la base de datos.");
var inbox = await ApiDenuncias.LoadInboxAsync(CancellationToken.None);
Contexts.Clear();
Contexts.AddRange(inbox.Contexts);
Reports.Clear();
Reports.AddRange(inbox.Reports);
DetailCache.Clear();
UserInboxState = inbox.UserState;
ApplyFilters();
}
catch (UnauthorizedAccessException ex)
{
await LoadSessionStateAsync();
Reports.Clear();
VisibleReports.Clear();
SelectedIds.Clear();
SetStatus(ex.Message, "alert-warning");
}
catch (Exception ex)
{
SetStatus(ex.Message, "alert-danger");
}
finally
{
ReportsBusy = false;
}
}
private async Task ImportSelectedAsync()
{
if (!CanUseGlobalLeaks)
{
SetStatus("Renueva antes la sesion de GlobalLeaks para importar denuncias.", "alert-warning");
return;
}
var selectedReports = Reports
.Where(report => SelectedIds.Contains(report.Id))
.Where(CanUseReport)
.OrderBy(report => report.Progressive ?? 0)
.ToList();
if (selectedReports.Count == 0)
{
SetStatus("No hay denuncias accesibles seleccionadas para importar.", "alert-warning");
return;
}
var reportsFromOtherOwners = selectedReports
.Where(report => report.RequiresOwnerConfirmation)
.ToArray();
if (reportsFromOtherOwners.Length > 0)
{
var ownerSummary = string.Join(
Environment.NewLine,
reportsFromOtherOwners.Select(report =>
$"Denuncia #{report.Progressive ?? 0}: propiedad de {report.OwnerUsername}."));
var confirmed = await Dialogs.ConfirmAsync(
"Denuncias asignadas a otros usuarios",
$"Vas a importar denuncias asignadas a otros usuarios de tu grupo:{Environment.NewLine}{Environment.NewLine}{ownerSummary}{Environment.NewLine}{Environment.NewLine}La importación quedará registrada con tu usuario.",
"Importar denuncias");
if (!confirmed)
{
return;
}
}
ImportBusy = true;
var importedCount = 0;
var errors = new List<string>();
var importWarnings = new List<string>();
try
{
using var busy = Busy.Show(
"Importando denuncias",
$"Descargando y procesando {selectedReports.Count} denuncia(s) desde GlobalLeaks.",
selectedReports.Count);
var processed = 0;
foreach (var report in selectedReports)
{
processed++;
Busy.Update(
message: $"Procesando denuncia #{report.Progressive ?? 0}.",
detail: "Descarga, analisis del report y guardado seguro en la base de datos.",
current: processed,
total: selectedReports.Count);
try
{
var result = await ApiDenuncias.ImportReportAsync(
report,
report.RequiresOwnerConfirmation,
CancellationToken.None);
importedCount += result.ImportedCount;
errors.AddRange(result.Errors.Select(error => $"#{report.Progressive ?? 0}: {error}"));
if (result.Warnings is not null)
{
importWarnings.AddRange(result.Warnings.Select(warning => $"#{report.Progressive ?? 0}: {warning}"));
}
}
catch (UnauthorizedAccessException ex)
{
await LoadSessionStateAsync();
SetStatus(ex.Message, "alert-warning");
break;
}
catch (Exception ex)
{
errors.Add($"#{report.Progressive ?? 0}: {ex.Message}");
}
}
SelectedIds.Clear();
await LoadReportsAsync();
var warnings = selectedReports
.Where(report => report.DownloadedByAnotherUser || report.AlreadyInGestiona)
.Select(report => $"#{report.Progressive ?? 0}: {report.TrackingNote}")
.Where(message => !string.IsNullOrWhiteSpace(message))
.ToList();
warnings.AddRange(importWarnings);
if (errors.Count == 0 && warnings.Count == 0)
{
SetStatus($"Se han importado {importedCount} denuncia(s) desde GlobalLeaks.", "alert-success");
}
else if (errors.Count == 0 && importedCount == 0)
{
var parts = new List<string>
{
"No se ha incorporado ninguna denuncia nueva."
};
if (warnings.Count > 0)
{
parts.Add($"Avisos: {string.Join(" | ", warnings)}");
}
SetStatus(string.Join(" ", parts), "alert-warning");
}
else
{
var parts = new List<string>
{
$"Se han importado {importedCount} denuncia(s)."
};
if (warnings.Count > 0)
{
parts.Add($"Avisos: {string.Join(" | ", warnings)}");
}
if (errors.Count > 0)
{
parts.Add($"Incidencias: {string.Join(" | ", errors)}");
}
SetStatus(string.Join(" ", parts), errors.Count == 0 ? "alert-warning" : "alert-danger");
}
}
finally
{
ImportBusy = false;
}
}
private async Task OpenReportDetailAsync(ReportDto report)
{
if (report.Accessible == false)
{
SetStatus($"La denuncia #{report.Progressive ?? 0} no es accesible para este usuario en GlobalLeaks.", "alert-warning");
return;
}
if (!CanUseGlobalLeaks)
{
SetStatus("Renueva antes la sesion de GlobalLeaks para consultar el detalle.", "alert-warning");
return;
}
DetailModalVisible = true;
DetailReport = report;
DetailData = null;
DetailError = string.Empty;
DetailBusy = true;
await SetBodyScrollLockAsync(true);
try
{
using var busy = Busy.Show(
"Leyendo detalle de denuncia",
"Consultando mensajes y ficheros para identificar que contenido es nuevo.");
if (!DetailCache.TryGetValue(report.Id, out var cachedDetail))
{
cachedDetail = await ApiDenuncias.GetReportDetailAsync(report.Id, report.LastAccess, CancellationToken.None);
DetailCache[report.Id] = cachedDetail;
}
DetailData = cachedDetail;
}
catch (UnauthorizedAccessException ex)
{
await LoadSessionStateAsync();
DetailError = ex.Message;
}
catch (Exception ex)
{
DetailError = ex.Message;
}
finally
{
DetailBusy = false;
}
}
private async Task CloseReportDetail()
{
DetailModalVisible = false;
DetailReport = null;
DetailData = null;
DetailError = string.Empty;
await SetBodyScrollLockAsync(false);
}
public async ValueTask DisposeAsync()
{
await SetBodyScrollLockAsync(false);
}
private async Task SetBodyScrollLockAsync(bool locked)
{
try
{
await JSRuntime.InvokeVoidAsync("appSetBodyScrollLock", locked);
}
catch
{
// Si el circuito se esta cerrando, solo aseguramos que no reviente el componente.
}
}
private void ApplyFilters()
{
IEnumerable<ReportDto> filtered = Reports;
FilterWarningMessage = string.Empty;
FilterWarningCss = "alert-warning";
filtered = Filter switch
{
"new" => filtered.Where(report => string.IsNullOrWhiteSpace(report.AccessDate) || string.Equals(report.Status, "new", StringComparison.OrdinalIgnoreCase)),
"updated" => filtered.Where(report =>
report.CitizenHasNewActivity ||
(!report.ActivityAnalyzed && report.Updated)),
"receiver" => filtered.Where(report => report.ReceiverHasNewActivity),
_ => filtered
};
filtered = ApplyDateScope(filtered);
if (!string.IsNullOrWhiteSpace(SelectedChannel))
{
filtered = filtered.Where(report => string.Equals(report.ContextId, SelectedChannel, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(SearchTerm))
{
var search = SearchTerm.Trim();
filtered = filtered.Where(report =>
(report.Progressive?.ToString() ?? string.Empty).Contains(search, StringComparison.OrdinalIgnoreCase) ||
(report.ContextName ?? report.ContextId ?? string.Empty).Contains(search, StringComparison.OrdinalIgnoreCase));
}
VisibleReports = filtered
.OrderByDescending(report => report.Progressive ?? 0)
.ToList();
var validIds = Reports
.Where(CanUseReport)
.Select(report => report.Id)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
SelectedIds.RemoveWhere(id => !validIds.Contains(id));
}
private IEnumerable<ReportDto> ApplyDateScope(IEnumerable<ReportDto> reports)
{
if (DateScope == "since_mine")
{
if (UserInboxState.LastDownloadedReportMomentUtc is null)
{
FilterWarningMessage = "Este usuario aun no tiene descargas previas. Se muestran todas las denuncias del filtro seleccionado.";
FilterWarningCss = "alert-info";
return reports;
}
var lastMoment = UserInboxState.LastDownloadedReportMomentUtc.Value;
return reports.Where(report => GetEffectiveMoment(report) is DateTimeOffset moment && moment > lastMoment);
}
if (DateScope == "range")
{
var hasFrom = DateOnly.TryParseExact(DateFrom, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var fromDate);
var hasTo = DateOnly.TryParseExact(DateTo, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var toDate);
if (hasFrom && hasTo && fromDate > toDate)
{
FilterWarningMessage = "La fecha inicial no puede ser posterior a la final.";
FilterWarningCss = "alert-danger";
return Enumerable.Empty<ReportDto>();
}
var filtered = reports.Where(report =>
{
var moment = GetEffectiveMoment(report);
if (moment is null)
{
return false;
}
var reportDate = DateOnly.FromDateTime(moment.Value.UtcDateTime);
var matchesFrom = !hasFrom || reportDate >= fromDate;
var matchesTo = !hasTo || reportDate <= toDate;
return matchesFrom && matchesTo;
});
if (hasFrom &&
UserInboxState.LastDownloadedReportMomentUtc is DateTimeOffset lastDownloaded &&
fromDate > DateOnly.FromDateTime(lastDownloaded.UtcDateTime))
{
var gapExists = Reports.Any(report =>
{
var moment = GetEffectiveMoment(report);
if (moment is null)
{
return false;
}
var reportDate = DateOnly.FromDateTime(moment.Value.UtcDateTime);
return moment.Value > lastDownloaded && reportDate < fromDate;
});
if (gapExists)
{
FilterWarningMessage =
$"Ojo: entre tu ultima descarga ({lastDownloaded.ToLocalTime():dd/MM/yyyy HH:mm}) y el inicio del intervalo ({fromDate:dd/MM/yyyy}) hay denuncias o actualizaciones sin revisar.";
}
}
return filtered;
}
return reports;
}
private void ToggleSelection(ReportDto report, ChangeEventArgs args)
{
if (report.Accessible == false)
{
SelectedIds.Remove(report.Id);
StateHasChanged();
return;
}
var isChecked = GetCheckedValue(args);
if (isChecked)
{
SelectedIds.Add(report.Id);
}
else
{
SelectedIds.Remove(report.Id);
}
StateHasChanged();
}
private void ToggleSelectAll()
{
var selectableReports = VisibleReports
.Where(CanUseReport)
.ToList();
var shouldSelect = selectableReports.Count > 0 && !selectableReports.All(report => SelectedIds.Contains(report.Id));
foreach (var report in selectableReports)
{
if (shouldSelect)
{
SelectedIds.Add(report.Id);
}
else
{
SelectedIds.Remove(report.Id);
}
}
StateHasChanged();
}
private void OnFilterChanged(ChangeEventArgs args)
{
Filter = args.Value?.ToString() ?? "all";
ApplyFilters();
}
private void OnDateScopeChanged(ChangeEventArgs args)
{
DateScope = args.Value?.ToString() ?? "all";
if (DateScope != "range")
{
DateFrom = string.Empty;
DateTo = string.Empty;
}
ApplyFilters();
}
private void OnDateFromChanged(ChangeEventArgs args)
{
DateFrom = args.Value?.ToString() ?? string.Empty;
ApplyFilters();
}
private void OnDateToChanged(ChangeEventArgs args)
{
DateTo = args.Value?.ToString() ?? string.Empty;
ApplyFilters();
}
private void OnChannelChanged(ChangeEventArgs args)
{
SelectedChannel = args.Value?.ToString() ?? string.Empty;
ApplyFilters();
}
private void OnSearchChanged(ChangeEventArgs args)
{
SearchTerm = args.Value?.ToString() ?? string.Empty;
ApplyFilters();
}
private void SetStatus(string message, string cssClass)
{
StatusMessage = message;
StatusCss = cssClass;
}
private static bool GetCheckedValue(ChangeEventArgs args)
{
return args.Value switch
{
bool b => b,
string s when bool.TryParse(s, out var parsed) => parsed,
_ => false
};
}
private static string FormatDate(string? value)
{
return FormatOptionalDate(value) ?? "-";
}
private static string? FormatOptionalDate(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return DateTimeOffset.TryParse(value, out var parsed)
? parsed.ToLocalTime().ToString("dd/MM/yyyy HH:mm")
: null;
}
private static string FormatCitizenActivity(ReportDto report)
{
var citizenDate = FormatOptionalDate(report.CitizenLastActivity);
if (!string.IsNullOrWhiteSpace(citizenDate))
{
return citizenDate;
}
if (!report.ActivityAnalyzed)
{
return "No analizada";
}
var accessDate = FormatOptionalDate(report.WhistleblowerLastAccess);
if (!string.IsNullOrWhiteSpace(accessDate))
{
return accessDate;
}
return "Sin fecha";
}
private static string FormatReceiverActivity(ReportDto report)
{
var receiverDate = FormatOptionalDate(report.ReceiverLastActivity);
if (!string.IsNullOrWhiteSpace(receiverDate))
{
return string.IsNullOrWhiteSpace(report.ReceiverLastActivityAuthorName)
? receiverDate
: $"{receiverDate} · {report.ReceiverLastActivityAuthorName}";
}
if (!report.ActivityAnalyzed)
{
return "No analizada";
}
return "Sin actividad";
}
private static string GetReceiverActivityTitle(ReportDto report)
{
if (string.IsNullOrWhiteSpace(report.ReceiverLastActivityAuthorName))
{
return FormatReceiverActivity(report);
}
return $"Ultima actividad OAAF realizada por {report.ReceiverLastActivityAuthorName}.";
}
private static string FormatBytes(long? value)
{
if (value is null or <= 0)
{
return "Tamano no disponible";
}
var bytes = value.Value;
if (bytes < 1024)
{
return $"{bytes} B";
}
if (bytes < 1024 * 1024)
{
return $"{bytes / 1024d:0.#} KB";
}
return $"{bytes / 1024d / 1024d:0.#} MB";
}
private static string GetCommentAuthorLabel(ReportCommentDto comment)
=> IsWhistleblowerActivityType(comment.Type)
? "Denunciante"
: IsReceiverActivityType(comment.Type)
? string.IsNullOrWhiteSpace(comment.AuthorName)
? "Gestor OAAF"
: $"Gestor OAAF: {comment.AuthorName}"
: "Comentario";
private static bool IsWhistleblowerActivityType(string? value)
=> MatchesAny(value, "whistleblower", "citizen", "source", "tipper", "submitter", "denunciante");
private static bool IsReceiverActivityType(string? value)
=> MatchesAny(value, "receiver", "recipient", "admin", "administrator", "operator", "staff", "custodian", "moderator", "gestor", "oaaf");
private static bool MatchesAny(string? value, params string[] candidates)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var normalized = NormalizeActivityType(value);
return candidates.Any(candidate =>
{
var candidateToken = NormalizeActivityType(candidate);
return normalized == candidateToken ||
normalized.StartsWith(candidateToken + " ", StringComparison.Ordinal) ||
normalized.EndsWith(" " + candidateToken, StringComparison.Ordinal) ||
normalized.Contains(" " + candidateToken + " ", StringComparison.Ordinal);
});
}
private static string NormalizeActivityType(string value)
=> Regex.Replace(value.Trim().ToLowerInvariant(), @"[^a-z0-9]+", " ").Trim();
private static DateTimeOffset? GetEffectiveMoment(ReportDto report)
{
return ParseDate(report.CitizenLastActivity) ?? ParseDate(report.ReceiverLastActivity) ?? ParseDate(report.UpdateDate) ?? ParseDate(report.CreationDate);
}
private static DateTimeOffset? ParseDate(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return DateTimeOffset.TryParse(value.Replace("Z", "+00:00", StringComparison.Ordinal), out var parsed)
? parsed
: null;
}
private static string GetStatusLabel(ReportDto report)
{
if (string.IsNullOrWhiteSpace(report.AccessDate))
{
return "Sin leer";
}
if (report.CitizenHasNewActivity)
{
return "Actualización ciudadano";
}
if (IsReceiverOnlyUpdate(report))
{
return "Actividad OAAF";
}
if (report.AlreadyInGestiona && !report.ActivityAnalyzed)
{
return "Sin comprobar";
}
if (IsUpToDateInGestiona(report))
{
return "Actualizada";
}
return string.Equals(report.Status, "closed", StringComparison.OrdinalIgnoreCase)
? "Cerrada"
: "Nueva denuncia";
}
private static string GetStatusBadgeCss(ReportDto report)
{
if (string.IsNullOrWhiteSpace(report.AccessDate))
{
return "bg-warning text-dark";
}
if (report.CitizenHasNewActivity)
{
return "bg-info text-dark";
}
if (IsReceiverOnlyUpdate(report))
{
return "bg-secondary";
}
if (report.AlreadyInGestiona && !report.ActivityAnalyzed)
{
return "bg-warning text-dark";
}
if (IsUpToDateInGestiona(report))
{
return "bg-light text-dark";
}
return string.Equals(report.Status, "closed", StringComparison.OrdinalIgnoreCase)
? "bg-secondary"
: "bg-primary";
}
private static bool IsUpToDateInGestiona(ReportDto report)
{
if (!report.AlreadyInGestiona)
{
return false;
}
var lastUpload = ParseDate(report.LastGestionaUploadAt);
var latestActivity = new[]
{
ParseDate(report.CitizenLastActivity),
ParseDate(report.ReceiverLastActivity)
}
.Where(value => value is not null)
.Select(value => value!.Value)
.DefaultIfEmpty()
.Max();
if (lastUpload is not null && latestActivity != default)
{
return lastUpload.Value >= latestActivity;
}
return report.ActivityAnalyzed &&
!report.CitizenHasNewActivity &&
!report.ReceiverHasNewActivity;
}
private static string GetStatusHelp(ReportDto report)
=> GetStatusLabel(report) switch
{
"Nueva denuncia" => "La denuncia todavía no tiene expediente creado desde la aplicación y está pendiente de actuación.",
"Actualización ciudadano" => "El ciudadano ha añadido un comentario, un fichero o una modificación posterior a la última subida a Gestiona.",
"Actividad OAAF" => "Un gestor de la OAAF ha realizado una comunicación o añadido un fichero posterior a la última subida a Gestiona.",
"Actualizada" => "La última subida a Gestiona es igual o posterior a la actividad del ciudadano y de la OAAF detectada en el buzón.",
"Sin comprobar" => "No se ha podido comparar en este momento la actividad del buzón con la última subida a Gestiona.",
"Cerrada" => "La denuncia figura cerrada en GlobalLeaks.",
"Sin leer" => "La denuncia todavía no se ha abierto con este usuario en GlobalLeaks.",
_ => string.Empty
};
private static string GetAccessLabel(ReportDto report)
=> report.Accessible switch
{
true => "Accesible",
false => "Sin acceso",
_ => "Sin dato"
};
private static string GetAccessBadgeCss(ReportDto report)
=> report.Accessible switch
{
true => "bg-success",
false => "bg-danger",
_ => "bg-light text-dark"
};
private static string GetAccessHelp(ReportDto report)
=> report.Accessible switch
{
true => "Tu usuario gestor del buzón tiene acceso a esta denuncia.",
false => "GlobalLeaks indica que tu usuario gestor del buzón no tiene acceso a esta denuncia.",
_ => "GlobalLeaks no ha informado si tu usuario puede acceder a esta denuncia."
};
private static bool CanUseReport(ReportDto report)
=> report.Accessible != false;
private static bool IsReceiverOnlyUpdate(ReportDto report)
=> report.AlreadyInGestiona &&
report.ActivityAnalyzed &&
!report.CitizenHasNewActivity &&
report.ReceiverHasNewActivity;
private static string? GetReportActionBlockReason(ReportDto report)
{
if (report.Accessible == false)
{
return "GlobalLeaks indica que esta denuncia no es accesible para este usuario.";
}
return null;
}
private static string GetReportDetailTitle(ReportDto report)
{
if (report.Accessible == false)
{
return "GlobalLeaks indica que esta denuncia no es accesible para este usuario.";
}
return "Consulta mensajes y ficheros. GlobalLeaks puede marcar la denuncia como leida.";
}
private static string GetTrackingLabel(ReportDto report)
{
if (report.AlreadyInGestiona)
{
return "Expediente creado";
}
if (report.DownloadedByAnotherUser)
{
return "Descargada por otro";
}
if (report.DownloadedByCurrentUser)
{
return "Descargada por ti";
}
if (report.AlreadyImported)
{
return "Ya incorporada";
}
return "Pendiente";
}
private static string GetTrackingBadgeCss(ReportDto report)
{
if (report.AlreadyInGestiona)
{
return "bg-success";
}
if (report.DownloadedByAnotherUser)
{
return "bg-warning text-dark";
}
if (report.DownloadedByCurrentUser)
{
return "bg-secondary";
}
if (report.AlreadyImported)
{
return "bg-info text-dark";
}
return "bg-light text-dark";
}
private static string GetTrackingHelp(ReportDto report)
{
if (report.AlreadyInGestiona)
{
return "La denuncia ya tiene un expediente creado en Gestiona. El estado indica si existe actividad posterior pendiente.";
}
if (report.DownloadedByAnotherUser)
{
return string.IsNullOrWhiteSpace(report.TrackingNote)
? "Otro usuario ya la descargó en la aplicación, pero todavía no se ha materializado la subida a Gestiona."
: $"{report.TrackingNote}. Todavía no se ha materializado la subida a Gestiona.";
}
if (report.DownloadedByCurrentUser)
{
return string.IsNullOrWhiteSpace(report.TrackingNote)
? "Ya la descargaste en la aplicación, pero todavía no se ha materializado la subida a Gestiona."
: $"{report.TrackingNote}. Todavía no se ha materializado la subida a Gestiona.";
}
if (report.AlreadyImported)
{
return "La denuncia está incorporada a la aplicación, pero todavía no se ha subido a Gestiona.";
}
return "La denuncia todavía no se ha descargado ni subido a Gestiona.";
}
private static string? GetReportRowCss(ReportDto report)
{
if (report.Accessible == false)
{
return "table-danger report-row-disabled";
}
if (IsReceiverOnlyUpdate(report))
{
return "table-secondary";
}
if (report.AlreadyInGestiona)
{
return "table-success";
}
if (report.DownloadedByAnotherUser)
{
return "table-warning";
}
return null;
}
}