feat: Aggiunto sistema completo di gestione profili per Data Coupler

- Creata nuova libreria Components con componenti Blazor riutilizzabili
  * ProfileSelector: dropdown per selezione profili salvati
  * ProfileSaver: componente per salvare configurazioni correnti come profili
  * ProfileManagement: modale per gestione profili salvati
  * ProfileQuickActions: bottoni azioni rapide per operazioni sui profili

- Esteso CredentialManager con entità e servizi per DataCouplerProfile
  * Aggiunto modello DataCouplerProfile con configurazioni mapping e metadati
  * Implementata migrazione Entity Framework per memorizzazione profili
  * Creato DataCouplerProfileService per operazioni CRUD
  * Aggiunto CredentialDbContextFactory per operazioni database design-time

- Migliorato componente principale DataCoupler con integrazione profili
  * Integrata funzionalità caricamento/salvataggio profili
  * Aggiunto selettore profili nella parte superiore dell'interfaccia
  * Mantenuta retrocompatibilità con funzionalità esistenti
  * Migliorata esperienza utente con gestione configurazioni salvate

- Aggiornata struttura progetto e dipendenze
  * Aggiunto progetto Components alla soluzione
  * Aggiornati riferimenti progetti e import
  * Rimosso progetto obsoleto TestDatabaseFix

Questo aggiornamento migliora significativamente il flusso di lavoro permettendo agli utenti di salvare, caricare e gestire configurazioni complete di accoppiamento dati come
This commit is contained in:
2025-07-02 00:00:05 +02:00
parent 1435c013d3
commit 7e450a358b
34 changed files with 2430 additions and 422 deletions
+22
View File
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="9.0.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CredentialManager\CredentialManager.csproj" />
</ItemGroup>
</Project>
+180
View File
@@ -0,0 +1,180 @@
@* Componente per la gestione completa dei profili *@
<!-- Modal per la gestione profili -->
@if (ShowModal)
{
<div class="modal fade show d-block" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-cogs"></i> Gestione Profili
</h5>
<button type="button" class="btn-close" @onclick="CloseModal"></button>
</div>
<div class="modal-body">
@if (IsLoading)
{
<div class="text-center py-4">
<div class="spinner-border" role="status">
<span class="visually-hidden">Caricamento...</span>
</div>
<p class="mt-2">Caricamento profili...</p>
</div>
}
else if (Profiles == null || !Profiles.Any())
{
<div class="text-center py-4">
<i class="fas fa-folder-open fa-3x text-muted mb-3"></i>
<h6 class="text-muted">Nessun profilo salvato</h6>
<p class="text-muted">Configura una connessione e salva il tuo primo profilo!</p>
</div>
}
else
{
<!-- Filtro di ricerca -->
<div class="mb-3">
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-search"></i>
</span>
<input type="text" class="form-control" @bind="SearchTerm" @oninput="FilterProfiles"
placeholder="Cerca profili per nome o descrizione..." />
</div>
</div>
<!-- Lista profili -->
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Nome</th>
<th>Fonte → Destinazione</th>
<th>Creato</th>
<th>Ultimo Uso</th>
<th width="120">Azioni</th>
</tr>
</thead>
<tbody>
@foreach (var profile in GetFilteredProfiles())
{
<tr>
<td>
<strong>@profile.Name</strong>
@if (!string.IsNullOrEmpty(profile.Description))
{
<br><small class="text-muted">@profile.Description</small>
}
</td>
<td>
<span class="badge bg-primary me-1">@GetTypeLabel(profile.SourceType)</span>
<i class="fas fa-arrow-right text-muted"></i>
<span class="badge bg-success ms-1">@GetTypeLabel(profile.DestinationType)</span>
<br>
<small class="text-muted">
@GetProfileSummary(profile)
</small>
</td>
<td>
<small>
@profile.CreatedAt.ToString("dd/MM/yyyy HH:mm")
@if (!string.IsNullOrEmpty(profile.CreatedBy))
{
<br><span class="text-muted">da @profile.CreatedBy</span>
}
</small>
</td>
<td>
<small>
@(profile.LastUsedAt?.ToString("dd/MM/yyyy HH:mm") ?? "Mai utilizzato")
</small>
</td>
<td>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-primary"
@onclick="() => LoadProfile(profile)"
title="Carica questo profilo">
<i class="fas fa-download"></i>
</button>
<button type="button" class="btn btn-outline-danger"
@onclick="() => ConfirmDelete(profile)"
title="Elimina questo profilo">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
@if (!GetFilteredProfiles().Any())
{
<div class="text-center py-3">
<i class="fas fa-search text-muted"></i>
<p class="text-muted mb-0">Nessun profilo corrisponde alla ricerca</p>
</div>
}
}
@if (!string.IsNullOrEmpty(Message))
{
<div class="alert alert-@(MessageType) mt-3">
<i class="fas fa-@(MessageType == "success" ? "check-circle" : "exclamation-triangle")"></i>
@Message
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CloseModal">
<i class="fas fa-times"></i> Chiudi
</button>
</div>
</div>
</div>
</div>
}
<!-- Modal conferma eliminazione -->
@if (ShowDeleteConfirm && ProfileToDelete != null)
{
<div class="modal fade show d-block" style="background-color: rgba(0,0,0,0.7);">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title text-danger">
<i class="fas fa-exclamation-triangle"></i> Conferma Eliminazione
</h5>
</div>
<div class="modal-body">
<p>Sei sicuro di voler eliminare il profilo <strong>"@ProfileToDelete.Name"</strong>?</p>
@if (!string.IsNullOrEmpty(ProfileToDelete.Description))
{
<p class="text-muted">@ProfileToDelete.Description</p>
}
<div class="alert alert-warning">
<i class="fas fa-info-circle"></i>
<strong>Attenzione:</strong> Questa operazione non può essere annullata.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">
<i class="fas fa-times"></i> Annulla
</button>
<button type="button" class="btn btn-danger" @onclick="DeleteProfile" disabled="@IsDeleting">
@if (IsDeleting)
{
<span class="spinner-border spinner-border-sm" role="status"></span>
}
else
{
<i class="fas fa-trash"></i>
}
Elimina
</button>
</div>
</div>
</div>
</div>
}
+145
View File
@@ -0,0 +1,145 @@
using Microsoft.AspNetCore.Components;
using CredentialManager.Models;
namespace Components;
public partial class ProfileManagement
{
[Parameter] public bool ShowModal { get; set; }
[Parameter] public List<DataCouplerProfile>? Profiles { get; set; }
[Parameter] public EventCallback OnCloseModal { get; set; }
[Parameter] public EventCallback<DataCouplerProfile> OnProfileLoaded { get; set; }
[Parameter] public EventCallback<int> OnProfileDeleted { get; set; }
[Parameter] public bool IsLoading { get; set; }
private string SearchTerm { get; set; } = "";
private string Message { get; set; } = "";
private string MessageType { get; set; } = "info";
private bool ShowDeleteConfirm { get; set; } = false;
private bool IsDeleting { get; set; } = false;
private DataCouplerProfile? ProfileToDelete { get; set; }
private void FilterProfiles(ChangeEventArgs e)
{
SearchTerm = e.Value?.ToString() ?? "";
}
private IEnumerable<DataCouplerProfile> GetFilteredProfiles()
{
if (Profiles == null)
return Enumerable.Empty<DataCouplerProfile>();
if (string.IsNullOrWhiteSpace(SearchTerm))
return Profiles;
var searchLower = SearchTerm.ToLower();
return Profiles.Where(p =>
p.Name.ToLower().Contains(searchLower) ||
(!string.IsNullOrEmpty(p.Description) && p.Description.ToLower().Contains(searchLower))
);
}
private string GetTypeLabel(string type)
{
return type switch
{
"database" => "DB",
"file" => "File",
"rest" => "REST",
_ => type.ToUpper()
};
}
private string GetProfileSummary(DataCouplerProfile profile)
{
var parts = new List<string>();
// Fonte
if (!string.IsNullOrEmpty(profile.SourceTable))
parts.Add($"da {profile.SourceTable}");
else if (!string.IsNullOrEmpty(profile.SourceFilePath))
parts.Add($"da {Path.GetFileName(profile.SourceFilePath)}");
// Destinazione
if (!string.IsNullOrEmpty(profile.DestinationTable))
parts.Add($"verso {profile.DestinationTable}");
else if (!string.IsNullOrEmpty(profile.DestinationEndpoint))
parts.Add($"verso {profile.DestinationEndpoint}");
return string.Join(" ", parts);
}
private async Task CloseModal()
{
SearchTerm = "";
Message = "";
await OnCloseModal.InvokeAsync();
}
private async Task LoadProfile(DataCouplerProfile profile)
{
Message = $"Caricamento profilo '{profile.Name}'...";
MessageType = "info";
await OnProfileLoaded.InvokeAsync(profile);
Message = $"Profilo '{profile.Name}' caricato con successo!";
MessageType = "success";
// Chiudi il modal dopo un breve delay
await Task.Delay(1000);
await CloseModal();
}
private void ConfirmDelete(DataCouplerProfile profile)
{
ProfileToDelete = profile;
ShowDeleteConfirm = true;
Message = "";
}
private void CancelDelete()
{
ProfileToDelete = null;
ShowDeleteConfirm = false;
}
private async Task DeleteProfile()
{
if (ProfileToDelete == null)
return;
IsDeleting = true;
try
{
await OnProfileDeleted.InvokeAsync(ProfileToDelete.Id);
Message = $"Profilo '{ProfileToDelete.Name}' eliminato con successo.";
MessageType = "success";
ShowDeleteConfirm = false;
ProfileToDelete = null;
}
catch (Exception ex)
{
Message = $"Errore nell'eliminazione: {ex.Message}";
MessageType = "danger";
}
finally
{
IsDeleting = false;
}
}
public void SetMessage(string message, string type = "info")
{
Message = message;
MessageType = type;
}
public void ClearMessage()
{
Message = "";
}
}
+115
View File
@@ -0,0 +1,115 @@
@* Componente per salvare la configurazione corrente come profilo *@
<div class="card mb-3">
<div class="card-header bg-success text-white">
<h6 class="mb-0">
<i class="fas fa-save"></i> Salva Configurazione Corrente
</h6>
</div>
<div class="card-body">
@if (!ShowSaveForm)
{
<button type="button" class="btn btn-success" @onclick="ShowSaveDialog" disabled="@(!CanSave)">
<i class="fas fa-plus"></i> Salva come Nuovo Profilo
</button>
@if (!CanSave)
{
<small class="text-muted d-block mt-1">
<i class="fas fa-info-circle"></i>
Configura fonte e destinazione per abilitare il salvataggio
</small>
}
}
else
{
<EditForm Model="ProfileData" OnValidSubmit="SaveProfile">
<DataAnnotationsValidator />
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Nome Profilo <span class="text-danger">*</span></label>
<InputText @bind-Value="ProfileData.Name" class="form-control"
placeholder="Es: Export Clienti a CRM" />
<ValidationMessage For="@(() => ProfileData.Name)" />
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Descrizione</label>
<InputText @bind-Value="ProfileData.Description" class="form-control"
placeholder="Descrizione opzionale del profilo" />
</div>
</div>
</div>
@if (!string.IsNullOrEmpty(SaveMessage))
{
<div class="alert alert-@(SaveMessageType) mb-3">
<i class="fas fa-@(SaveMessageType == "success" ? "check-circle" : "exclamation-triangle")"></i>
@SaveMessage
</div>
}
<!-- Anteprima Configurazione -->
<div class="mb-3">
<label class="form-label">Configurazione da salvare:</label>
<div class="bg-light p-3 rounded small">
<div class="row">
<div class="col-md-6">
<strong>Fonte:</strong> @GetSourceSummary()<br />
@if (!string.IsNullOrEmpty(SourceSchema))
{
<span class="text-muted">Schema: @SourceSchema</span><br />
}
@if (!string.IsNullOrEmpty(SourceTable))
{
<span class="text-muted">Tabella: @SourceTable</span>
}
</div>
<div class="col-md-6">
<strong>Destinazione:</strong> @GetDestinationSummary()<br />
@if (!string.IsNullOrEmpty(DestinationSchema))
{
<span class="text-muted">Schema: @DestinationSchema</span><br />
}
@if (!string.IsNullOrEmpty(DestinationTable))
{
<span class="text-muted">Tabella: @DestinationTable</span>
}
@if (!string.IsNullOrEmpty(DestinationEndpoint))
{
<span class="text-muted">Endpoint: @DestinationEndpoint</span>
}
</div>
</div>
@if (FieldMappings != null && FieldMappings.Any())
{
<hr class="my-2" />
<small class="text-muted">
<i class="fas fa-exchange-alt"></i>
@FieldMappings.Count mapping dei campi configurati
</small>
}
</div>
</div>
<div class="text-end">
<button type="button" class="btn btn-secondary me-2" @onclick="CancelSave">
<i class="fas fa-times"></i> Annulla
</button>
<button type="submit" class="btn btn-success" disabled="@IsSaving">
@if (IsSaving)
{
<span class="spinner-border spinner-border-sm" role="status"></span>
}
else
{
<i class="fas fa-save"></i>
}
Salva Profilo
</button>
</div>
</EditForm>
}
</div>
</div>
+123
View File
@@ -0,0 +1,123 @@
using Microsoft.AspNetCore.Components;
using CredentialManager.Models;
using System.ComponentModel.DataAnnotations;
namespace Components;
public partial class ProfileSaver
{
[Parameter] public bool CanSave { get; set; }
[Parameter] public string SourceType { get; set; } = "";
[Parameter] public int? SourceCredentialId { get; set; }
[Parameter] public string? SourceSchema { get; set; }
[Parameter] public string? SourceTable { get; set; }
[Parameter] public string? SourceFilePath { get; set; }
[Parameter] public string DestinationType { get; set; } = "";
[Parameter] public int? DestinationCredentialId { get; set; }
[Parameter] public string? DestinationSchema { get; set; }
[Parameter] public string? DestinationTable { get; set; }
[Parameter] public string? DestinationEndpoint { get; set; }
[Parameter] public List<FieldMappingDto>? FieldMappings { get; set; }
[Parameter] public EventCallback<DataCouplerProfileDto> OnProfileSaved { get; set; }
private bool ShowSaveForm { get; set; } = false;
private bool IsSaving { get; set; } = false;
private string SaveMessage { get; set; } = "";
private string SaveMessageType { get; set; } = "info";
private ProfileFormModel ProfileData { get; set; } = new();
private void ShowSaveDialog()
{
ProfileData = new ProfileFormModel();
ShowSaveForm = true;
SaveMessage = "";
}
private void CancelSave()
{
ShowSaveForm = false;
SaveMessage = "";
ProfileData = new();
}
private async Task SaveProfile()
{
IsSaving = true;
SaveMessage = "";
try
{
var profileDto = new DataCouplerProfileDto
{
Name = ProfileData.Name,
Description = ProfileData.Description,
SourceType = SourceType,
SourceCredentialId = SourceCredentialId,
SourceSchema = SourceSchema,
SourceTable = SourceTable,
SourceFilePath = SourceFilePath,
DestinationType = DestinationType,
DestinationCredentialId = DestinationCredentialId,
DestinationSchema = DestinationSchema,
DestinationTable = DestinationTable,
DestinationEndpoint = DestinationEndpoint,
FieldMappings = FieldMappings
};
await OnProfileSaved.InvokeAsync(profileDto);
SaveMessage = $"Profilo '{ProfileData.Name}' salvato con successo!";
SaveMessageType = "success";
// Reset form after successful save
await Task.Delay(1500); // Show success message briefly
ShowSaveForm = false;
ProfileData = new();
}
catch (Exception ex)
{
SaveMessage = $"Errore nel salvataggio: {ex.Message}";
SaveMessageType = "danger";
}
finally
{
IsSaving = false;
}
}
private string GetSourceSummary()
{
return SourceType switch
{
"database" => "Database",
"file" => "File Excel/CSV",
_ => "Non configurato"
};
}
private string GetDestinationSummary()
{
return DestinationType switch
{
"database" => "Database",
"rest" => "REST API",
_ => "Non configurato"
};
}
public void SetMessage(string message, string type = "info")
{
SaveMessage = message;
SaveMessageType = type;
}
public class ProfileFormModel
{
[Required(ErrorMessage = "Il nome del profilo è obbligatorio")]
[StringLength(100, ErrorMessage = "Il nome non può superare i 100 caratteri")]
public string Name { get; set; } = "";
[StringLength(500, ErrorMessage = "La descrizione non può superare i 500 caratteri")]
public string? Description { get; set; }
}
}
+56
View File
@@ -0,0 +1,56 @@
@* Componente per la selezione e caricamento dei profili *@
<div class="card mb-3">
<div class="card-header bg-info text-white">
<h6 class="mb-0">
<i class="fas fa-user-cog"></i> Gestione Profili
</h6>
</div>
<div class="card-body">
<div class="row">
<!-- Selezione Profilo Esistente -->
<div class="col-md-8">
<label class="form-label">Carica Profilo Salvato:</label>
<div class="input-group">
<select class="form-select" @onchange="OnProfileSelected">
<option value="">-- Seleziona un profilo --</option>
@if (Profiles != null)
{
@foreach (var profile in Profiles)
{
<option value="@profile.Id" selected="@(SelectedProfileId == profile.Id)">
@profile.Name @if (!string.IsNullOrEmpty(profile.Description)) { <span class="text-muted">- @profile.Description</span> }
</option>
}
}
</select>
<button type="button" class="btn btn-outline-primary" @onclick="LoadSelectedProfile"
disabled="@(SelectedProfileId == 0 || IsLoading)">
@if (IsLoading)
{
<span class="spinner-border spinner-border-sm" role="status"></span>
}
else
{
<i class="fas fa-download"></i>
}
Carica
</button>
</div>
@if (!string.IsNullOrEmpty(LoadMessage))
{
<div class="alert alert-@(LoadMessageType) alert-sm mt-2 mb-0">
<i class="fas fa-@(LoadMessageType == "success" ? "check-circle" : "exclamation-triangle")"></i>
@LoadMessage
</div>
}
</div>
<!-- Pulsante Gestione Profili -->
<div class="col-md-4 d-flex align-items-end">
<button type="button" class="btn btn-outline-secondary w-100" @onclick="OpenProfileManagement">
<i class="fas fa-cogs"></i> Gestisci Profili
</button>
</div>
</div>
</div>
</div>
+64
View File
@@ -0,0 +1,64 @@
using Microsoft.AspNetCore.Components;
using CredentialManager.Models;
namespace Components;
public partial class ProfileSelector
{
[Parameter] public List<DataCouplerProfile>? Profiles { get; set; }
[Parameter] public EventCallback<DataCouplerProfile> OnProfileLoaded { get; set; }
[Parameter] public EventCallback OnManageProfiles { get; set; }
[Parameter] public bool IsLoading { get; set; }
private int SelectedProfileId { get; set; }
private string LoadMessage { get; set; } = "";
private string LoadMessageType { get; set; } = "info";
private void OnProfileSelected(ChangeEventArgs e)
{
if (int.TryParse(e.Value?.ToString(), out int profileId))
{
SelectedProfileId = profileId;
}
else
{
SelectedProfileId = 0;
}
LoadMessage = "";
}
private async Task LoadSelectedProfile()
{
if (SelectedProfileId == 0 || Profiles == null)
return;
var selectedProfile = Profiles.FirstOrDefault(p => p.Id == SelectedProfileId);
if (selectedProfile != null)
{
LoadMessage = $"Profilo '{selectedProfile.Name}' caricato con successo!";
LoadMessageType = "success";
await OnProfileLoaded.InvokeAsync(selectedProfile);
}
else
{
LoadMessage = "Errore nel caricamento del profilo selezionato.";
LoadMessageType = "danger";
}
}
private async Task OpenProfileManagement()
{
await OnManageProfiles.InvokeAsync();
}
public void ClearMessage()
{
LoadMessage = "";
}
public void SetMessage(string message, string type = "info")
{
LoadMessage = message;
LoadMessageType = type;
}
}
+4
View File
@@ -0,0 +1,4 @@
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Forms
@using CredentialManager.Models
@using CredentialManager.Services
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<!--To inherit the global NuGet package sources remove the <clear/> line below -->
<clear />
<add key="nuget" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B