Files
Data-Coupler/Data_Coupler/Pages/RecordAssociations.razor
T
Alessio 51c61eabf7 feat: Implementa gestione intelligente della chiave sorgente con rilevamento PK
- 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
2025-06-28 02:05:59 +02:00

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}");
}
}
}