51c61eabf7
- Aggiunge rilevamento automatico Primary Key per connessioni database - Rimuove completamente il fallback automatico per lato sorgente - Implementa selezione manuale obbligatoria per file e sorgenti non-DB - Migliora UI con suggerimenti intelligenti e feedback visivo - Aggiunge validazione multi-livello (UI, pre-transfer, runtime) - Introduce metodo GetPrimaryKeyFieldAsync in IDatabaseManager - Modifica GenerateSourceKey per richiedere sempre campo specifico - Implementa controllo IsTransferButtonEnabled per validazione form Breaking changes: - La generazione automatica delle chiavi sorgente è stata rimossa - Il campo chiave sorgente è ora obbligatorio quando si usa il sistema associazioni Fixes: Risolve problema di discovery schema vuoto con selezione database
536 lines
23 KiB
Plaintext
536 lines
23 KiB
Plaintext
@page "/record-associations"
|
|
@using CredentialManager.Models
|
|
@using DataConnection.CredentialManagement.Interfaces
|
|
@using Microsoft.AspNetCore.Components.Forms
|
|
@using Microsoft.JSInterop
|
|
@inject IDataConnectionCredentialService CredentialService
|
|
@inject IJSRuntime JSRuntime
|
|
@inject ILogger<RecordAssociations> Logger
|
|
|
|
<PageTitle>Associazioni Record</PageTitle>
|
|
|
|
<div class="container-fluid">
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<h3><i class="fas fa-link"></i> Associazioni Record</h3>
|
|
<p class="text-muted">Visualizza e gestisci le associazioni tra record sorgente e destinazione</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filtri -->
|
|
<div class="row mb-4">
|
|
<div class="col-md-3">
|
|
<label class="form-label">Filtra per Sorgente:</label>
|
|
<input class="form-control" @bind="sourceFilter" @bind:event="oninput" @onkeyup="ApplyFilters" placeholder="Nome sorgente..." />
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Filtra per Entità:</label>
|
|
<input class="form-control" @bind="entityFilter" @bind:event="oninput" @onkeyup="ApplyFilters" placeholder="Nome entità..." />
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Filtra per Credenziale:</label>
|
|
<input class="form-control" @bind="credentialFilter" @bind:event="oninput" @onkeyup="ApplyFilters" placeholder="Nome credenziale..." />
|
|
</div>
|
|
<div class="col-md-3 d-flex align-items-end">
|
|
<button class="btn btn-outline-secondary me-2" @onclick="ClearFilters">
|
|
<i class="fas fa-times"></i> Pulisci Filtri
|
|
</button>
|
|
<button class="btn btn-primary" @onclick="RefreshAssociations">
|
|
<i class="fas fa-sync-alt"></i> Aggiorna
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Statistiche -->
|
|
<div class="row mb-4">
|
|
<div class="col-md-3">
|
|
<div class="card bg-primary text-white">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between">
|
|
<div>
|
|
<h4 class="card-title">@filteredAssociations.Count</h4>
|
|
<p class="card-text">Associazioni Totali</p>
|
|
</div>
|
|
<div class="align-self-center">
|
|
<i class="fas fa-link fa-2x"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card bg-success text-white">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between">
|
|
<div>
|
|
<h4 class="card-title">@filteredAssociations.Where(a => a.IsActive).Count()</h4>
|
|
<p class="card-text">Attive</p>
|
|
</div>
|
|
<div class="align-self-center">
|
|
<i class="fas fa-check-circle fa-2x"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card bg-warning text-white">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between">
|
|
<div>
|
|
<h4 class="card-title">@filteredAssociations.Where(a => !a.IsActive).Count()</h4>
|
|
<p class="card-text">Disattivate</p>
|
|
</div>
|
|
<div class="align-self-center">
|
|
<i class="fas fa-pause-circle fa-2x"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card bg-info text-white">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between">
|
|
<div>
|
|
<h4 class="card-title">@filteredAssociations.Select(a => a.SourceName).Distinct().Count()</h4>
|
|
<p class="card-text">Sorgenti Diverse</p>
|
|
</div>
|
|
<div class="align-self-center">
|
|
<i class="fas fa-database fa-2x"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabella Associazioni -->
|
|
@if (isLoading)
|
|
{
|
|
<div class="text-center">
|
|
<div class="spinner-border" role="status">
|
|
<span class="visually-hidden">Caricamento...</span>
|
|
</div>
|
|
<p>Caricamento associazioni...</p>
|
|
</div>
|
|
}
|
|
else if (!filteredAssociations.Any())
|
|
{
|
|
<div class="alert alert-info">
|
|
<i class="fas fa-info-circle"></i>
|
|
@if (!allAssociations.Any())
|
|
{
|
|
<span>Nessuna associazione trovata. Le associazioni vengono create automaticamente durante il trasferimento dati.</span>
|
|
}
|
|
else
|
|
{
|
|
<span>Nessuna associazione corrisponde ai filtri applicati. Prova a modificare i criteri di ricerca.</span>
|
|
}
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="mb-0">
|
|
<i class="fas fa-table"></i> Associazioni Record
|
|
<span class="badge bg-primary ms-2">@filteredAssociations.Count</span>
|
|
</h5>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-striped table-hover mb-0">
|
|
<thead class="table-dark">
|
|
<tr>
|
|
<th>Sorgente</th>
|
|
<th>Tipo</th>
|
|
<th>Chiave Sorgente</th>
|
|
<th>Entità Destinazione</th>
|
|
<th>ID Destinazione</th>
|
|
<th>Credenziale REST</th>
|
|
<th>Stato</th>
|
|
<th>Creata</th>
|
|
<th>Aggiornata</th>
|
|
<th>Azioni</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var association in pagedAssociations)
|
|
{
|
|
<tr class="@(association.IsActive ? "" : "table-secondary")">
|
|
<td>
|
|
<strong>@association.SourceName</strong>
|
|
</td>
|
|
<td>
|
|
<span class="badge @(association.SourceType == "database" ? "bg-primary" : "bg-info")">
|
|
@association.SourceType
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<code class="small">@association.SourceKey</code>
|
|
</td>
|
|
<td>
|
|
<strong>@association.DestinationEntity</strong>
|
|
</td>
|
|
<td>
|
|
<code class="small">@association.DestinationId</code>
|
|
</td>
|
|
<td>
|
|
<span class="badge bg-secondary">@association.RestCredentialName</span>
|
|
</td>
|
|
<td>
|
|
@if (association.IsActive)
|
|
{
|
|
<span class="badge bg-success">
|
|
<i class="fas fa-check"></i> Attiva
|
|
</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-warning">
|
|
<i class="fas fa-pause"></i> Disattivata
|
|
</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
<small class="text-muted">
|
|
@association.CreatedAt.ToString("dd/MM/yyyy HH:mm")
|
|
</small>
|
|
</td>
|
|
<td>
|
|
<small class="text-muted">
|
|
@(association.UpdatedAt?.ToString("dd/MM/yyyy HH:mm") ?? "-")
|
|
</small>
|
|
</td>
|
|
<td>
|
|
<div class="btn-group btn-group-sm">
|
|
@if (association.IsActive)
|
|
{
|
|
<button class="btn btn-warning" @onclick="() => DeactivateAssociation(association.Id)" title="Disattiva">
|
|
<i class="fas fa-pause"></i>
|
|
</button>
|
|
}
|
|
else
|
|
{
|
|
<button class="btn btn-success" @onclick="() => ActivateAssociation(association.Id)" title="Riattiva">
|
|
<i class="fas fa-play"></i>
|
|
</button>
|
|
}
|
|
<button class="btn btn-danger" @onclick="() => DeleteAssociation(association.Id)" title="Elimina definitivamente">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
@if (!string.IsNullOrEmpty(association.AdditionalInfo))
|
|
{
|
|
<button class="btn btn-info" @onclick="() => ShowAdditionalInfo(association)" title="Mostra dettagli">
|
|
<i class="fas fa-info"></i>
|
|
</button>
|
|
}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Paginazione -->
|
|
@if (filteredAssociations.Count > pageSize)
|
|
{
|
|
<nav class="mt-3">
|
|
<ul class="pagination justify-content-center">
|
|
<li class="page-item @(currentPage == 1 ? "disabled" : "")">
|
|
<button class="page-link" @onclick="() => ChangePage(currentPage - 1)">Precedente</button>
|
|
</li>
|
|
|
|
@for (int i = Math.Max(1, currentPage - 2); i <= Math.Min(totalPages, currentPage + 2); i++)
|
|
{
|
|
var pageNum = i;
|
|
<li class="page-item @(currentPage == pageNum ? "active" : "")">
|
|
<button class="page-link" @onclick="() => ChangePage(pageNum)">@pageNum</button>
|
|
</li>
|
|
}
|
|
|
|
<li class="page-item @(currentPage == totalPages ? "disabled" : "")">
|
|
<button class="page-link" @onclick="() => ChangePage(currentPage + 1)">Successivo</button>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
}
|
|
}
|
|
|
|
<!-- Azioni di massa -->
|
|
@if (filteredAssociations.Any())
|
|
{
|
|
<div class="row mt-4">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h6 class="mb-0">Azioni di Massa</h6>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="btn-group">
|
|
<button class="btn btn-warning" @onclick="DeactivateAllInactive">
|
|
<i class="fas fa-pause"></i> Disattiva Tutte Inattive
|
|
</button>
|
|
<button class="btn btn-danger" @onclick="DeleteAllInactive">
|
|
<i class="fas fa-trash"></i> Elimina Tutte Disattivate
|
|
</button>
|
|
<button class="btn btn-info" @onclick="ExportAssociations">
|
|
<i class="fas fa-download"></i> Esporta CSV
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
@code {
|
|
private List<RecordAssociation> allAssociations = new();
|
|
private List<RecordAssociation> filteredAssociations = new();
|
|
private List<RecordAssociation> pagedAssociations = new();
|
|
private bool isLoading = true;
|
|
|
|
// Filtri
|
|
private string sourceFilter = "";
|
|
private string entityFilter = "";
|
|
private string credentialFilter = "";
|
|
|
|
// Paginazione
|
|
private int currentPage = 1;
|
|
private int pageSize = 25;
|
|
private int totalPages => (int)Math.Ceiling((double)filteredAssociations.Count / pageSize);
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
await RefreshAssociations();
|
|
}
|
|
|
|
private async Task RefreshAssociations()
|
|
{
|
|
try
|
|
{
|
|
isLoading = true;
|
|
allAssociations = await CredentialService.GetAllActiveRecordAssociationsAsync();
|
|
ApplyFilters();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Errore nel caricamento delle associazioni");
|
|
await JSRuntime.InvokeVoidAsync("alert", $"Errore nel caricamento delle associazioni: {ex.Message}");
|
|
}
|
|
finally
|
|
{
|
|
isLoading = false;
|
|
}
|
|
}
|
|
|
|
private void ApplyFilters()
|
|
{
|
|
filteredAssociations = allAssociations.Where(a =>
|
|
(string.IsNullOrEmpty(sourceFilter) || a.SourceName.Contains(sourceFilter, StringComparison.OrdinalIgnoreCase)) &&
|
|
(string.IsNullOrEmpty(entityFilter) || a.DestinationEntity.Contains(entityFilter, StringComparison.OrdinalIgnoreCase)) &&
|
|
(string.IsNullOrEmpty(credentialFilter) || a.RestCredentialName.Contains(credentialFilter, StringComparison.OrdinalIgnoreCase))
|
|
).OrderByDescending(a => a.CreatedAt).ToList();
|
|
|
|
currentPage = 1;
|
|
UpdatePagedAssociations();
|
|
StateHasChanged();
|
|
}
|
|
|
|
private void ClearFilters()
|
|
{
|
|
sourceFilter = "";
|
|
entityFilter = "";
|
|
credentialFilter = "";
|
|
ApplyFilters();
|
|
}
|
|
|
|
private void ChangePage(int page)
|
|
{
|
|
if (page >= 1 && page <= totalPages)
|
|
{
|
|
currentPage = page;
|
|
UpdatePagedAssociations();
|
|
}
|
|
}
|
|
|
|
private void UpdatePagedAssociations()
|
|
{
|
|
var startIndex = (currentPage - 1) * pageSize;
|
|
pagedAssociations = filteredAssociations.Skip(startIndex).Take(pageSize).ToList();
|
|
}
|
|
|
|
private async Task DeactivateAssociation(int id)
|
|
{
|
|
if (await JSRuntime.InvokeAsync<bool>("confirm", "Sei sicuro di voler disattivare questa associazione?"))
|
|
{
|
|
try
|
|
{
|
|
var success = await CredentialService.DeactivateRecordAssociationAsync(id);
|
|
if (success)
|
|
{
|
|
await JSRuntime.InvokeVoidAsync("alert", "Associazione disattivata con successo!");
|
|
await RefreshAssociations();
|
|
}
|
|
else
|
|
{
|
|
await JSRuntime.InvokeVoidAsync("alert", "Errore nella disattivazione dell'associazione.");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Errore nella disattivazione dell'associazione {Id}", id);
|
|
await JSRuntime.InvokeVoidAsync("alert", $"Errore: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task ActivateAssociation(int id)
|
|
{
|
|
try
|
|
{
|
|
var association = allAssociations.FirstOrDefault(a => a.Id == id);
|
|
if (association != null)
|
|
{
|
|
association.IsActive = true;
|
|
var success = await CredentialService.UpdateRecordAssociationAsync(association);
|
|
if (success)
|
|
{
|
|
await JSRuntime.InvokeVoidAsync("alert", "Associazione riattivata con successo!");
|
|
await RefreshAssociations();
|
|
}
|
|
else
|
|
{
|
|
await JSRuntime.InvokeVoidAsync("alert", "Errore nella riattivazione dell'associazione.");
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Errore nella riattivazione dell'associazione {Id}", id);
|
|
await JSRuntime.InvokeVoidAsync("alert", $"Errore: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task DeleteAssociation(int id)
|
|
{
|
|
if (await JSRuntime.InvokeAsync<bool>("confirm", "Sei sicuro di voler eliminare definitivamente questa associazione? Questa azione non può essere annullata."))
|
|
{
|
|
try
|
|
{
|
|
var success = await CredentialService.DeleteRecordAssociationAsync(id);
|
|
if (success)
|
|
{
|
|
await JSRuntime.InvokeVoidAsync("alert", "Associazione eliminata con successo!");
|
|
await RefreshAssociations();
|
|
}
|
|
else
|
|
{
|
|
await JSRuntime.InvokeVoidAsync("alert", "Errore nell'eliminazione dell'associazione.");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Errore nell'eliminazione dell'associazione {Id}", id);
|
|
await JSRuntime.InvokeVoidAsync("alert", $"Errore: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task ShowAdditionalInfo(RecordAssociation association)
|
|
{
|
|
var info = $"Informazioni aggiuntive per l'associazione:\n\n";
|
|
info += $"ID: {association.Id}\n";
|
|
info += $"Sorgente: {association.SourceName} ({association.SourceType})\n";
|
|
info += $"Chiave Sorgente: {association.SourceKey}\n";
|
|
info += $"Destinazione: {association.DestinationEntity}\n";
|
|
info += $"ID Destinazione: {association.DestinationId}\n";
|
|
info += $"Credenziale REST: {association.RestCredentialName}\n";
|
|
info += $"Creata: {association.CreatedAt}\n";
|
|
if (association.UpdatedAt.HasValue)
|
|
info += $"Aggiornata: {association.UpdatedAt}\n";
|
|
info += $"Stato: {(association.IsActive ? "Attiva" : "Disattivata")}\n";
|
|
if (!string.IsNullOrEmpty(association.AdditionalInfo))
|
|
info += $"\nInformazioni aggiuntive:\n{association.AdditionalInfo}";
|
|
|
|
await JSRuntime.InvokeVoidAsync("alert", info);
|
|
}
|
|
|
|
private async Task DeactivateAllInactive()
|
|
{
|
|
if (await JSRuntime.InvokeAsync<bool>("confirm", "Sei sicuro di voler disattivare tutte le associazioni che non sono attualmente in uso?"))
|
|
{
|
|
try
|
|
{
|
|
// Implementa logica per disattivare associazioni inattive
|
|
await JSRuntime.InvokeVoidAsync("alert", "Funzionalità in via di sviluppo.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Errore nella disattivazione di massa");
|
|
await JSRuntime.InvokeVoidAsync("alert", $"Errore: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task DeleteAllInactive()
|
|
{
|
|
if (await JSRuntime.InvokeAsync<bool>("confirm", "Sei sicuro di voler eliminare definitivamente tutte le associazioni disattivate? Questa azione non può essere annullata."))
|
|
{
|
|
try
|
|
{
|
|
var inactiveAssociations = allAssociations.Where(a => !a.IsActive).ToList();
|
|
var deletedCount = 0;
|
|
|
|
foreach (var association in inactiveAssociations)
|
|
{
|
|
if (await CredentialService.DeleteRecordAssociationAsync(association.Id))
|
|
{
|
|
deletedCount++;
|
|
}
|
|
}
|
|
|
|
await JSRuntime.InvokeVoidAsync("alert", $"Eliminate {deletedCount} associazioni disattivate.");
|
|
await RefreshAssociations();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Errore nell'eliminazione di massa");
|
|
await JSRuntime.InvokeVoidAsync("alert", $"Errore: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task ExportAssociations()
|
|
{
|
|
try
|
|
{
|
|
var csv = "Sorgente,Tipo,Chiave Sorgente,Entità Destinazione,ID Destinazione,Credenziale REST,Stato,Creata,Aggiornata\n";
|
|
|
|
foreach (var association in filteredAssociations)
|
|
{
|
|
csv += $"\"{association.SourceName}\",\"{association.SourceType}\",\"{association.SourceKey}\",";
|
|
csv += $"\"{association.DestinationEntity}\",\"{association.DestinationId}\",\"{association.RestCredentialName}\",";
|
|
csv += $"\"{(association.IsActive ? "Attiva" : "Disattivata")}\",\"{association.CreatedAt:dd/MM/yyyy HH:mm}\",";
|
|
csv += $"\"{(association.UpdatedAt?.ToString("dd/MM/yyyy HH:mm") ?? "")}\"\n";
|
|
}
|
|
|
|
var fileName = $"associazioni_record_{DateTime.Now:yyyyMMdd_HHmmss}.csv";
|
|
var bytes = System.Text.Encoding.UTF8.GetBytes(csv);
|
|
var base64 = Convert.ToBase64String(bytes);
|
|
|
|
await JSRuntime.InvokeVoidAsync("downloadFile", fileName, base64);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Errore nell'esportazione delle associazioni");
|
|
await JSRuntime.InvokeVoidAsync("alert", $"Errore nell'esportazione: {ex.Message}");
|
|
}
|
|
}
|
|
}
|