WIP: Record history

This commit is contained in:
2025-12-01 18:37:09 +01:00
parent c8ded1f0a4
commit 0c6848556b
11 changed files with 1176 additions and 0 deletions

View File

@@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using System.Text.Json;
using Google.Apis.Sheets.v4; using Google.Apis.Sheets.v4;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -781,6 +782,9 @@ public class LayersController : Controller
_db.Records.Add(record); _db.Records.Add(record);
// Capture history
CaptureRecordHistory(record, RecordChangeType.Created, Guid.Parse(userId));
// Update layer modified info // Update layer modified info
layer.ModifiedAt = DateTime.UtcNow; layer.ModifiedAt = DateTime.UtcNow;
layer.ModifiedById = Guid.Parse(userId); layer.ModifiedById = Guid.Parse(userId);
@@ -851,10 +855,17 @@ public class LayersController : Controller
return BadRequest("Desc1 is required"); return BadRequest("Desc1 is required");
} }
// Capture old values before updating
var oldCode = record.Code;
var oldDesc1 = record.Desc1;
record.Desc1 = recordDto.Desc1; record.Desc1 = recordDto.Desc1;
record.ModifiedAt = DateTime.UtcNow; record.ModifiedAt = DateTime.UtcNow;
record.ModifiedById = Guid.Parse(userId); record.ModifiedById = Guid.Parse(userId);
// Capture history
CaptureRecordHistory(record, RecordChangeType.Updated, Guid.Parse(userId), oldCode, oldDesc1);
// Update layer modified info // Update layer modified info
layer.ModifiedAt = DateTime.UtcNow; layer.ModifiedAt = DateTime.UtcNow;
layer.ModifiedById = Guid.Parse(userId); layer.ModifiedById = Guid.Parse(userId);
@@ -915,6 +926,9 @@ public class LayersController : Controller
return NotFound("Record not found"); return NotFound("Record not found");
} }
// Capture history before deleting
CaptureRecordHistory(record, RecordChangeType.Deleted, Guid.Parse(userId));
_db.Records.Remove(record); _db.Records.Remove(record);
// Update layer modified info // Update layer modified info
@@ -933,4 +947,165 @@ public class LayersController : Controller
return BadRequest(e.ToString()); return BadRequest(e.ToString());
} }
} }
[HttpGet]
[Route("{layerId:guid}/records/{recordId:guid}/history")]
public IActionResult GetRecordHistory(Guid layerId, Guid recordId)
{
try
{
var history = _db.RecordHistory
.Include(h => h.ChangedBy)
.Where(h => h.RecordId == recordId && h.LayerId == layerId)
.OrderByDescending(h => h.ChangedAt)
.AsNoTracking()
.Select(h => new RecordHistoryDto
{
Id = h.Id,
RecordId = h.RecordId,
LayerId = h.LayerId,
ChangedAt = h.ChangedAt,
ChangedById = h.ChangedById,
ChangedByName = h.ChangedBy != null ? h.ChangedBy.UserName ?? h.ChangedBy.Email : "Unknown",
ChangeType = h.ChangeType.ToString(),
Code = h.Code,
Desc1 = h.Desc1,
ChangedFields = h.ChangedFields,
ChangesSummary = h.ChangesSummary,
FormattedChange = FormatHistoryChange(h)
})
.ToList();
_logger.LogDebug("GetRecordHistory: Retrieved {Count} history entries for record {RecordId}", history.Count, recordId);
return Ok(history);
}
catch (Exception e)
{
_logger.LogError(e, "GetRecordHistory: Error retrieving history for record {RecordId}", recordId);
return BadRequest(e.ToString());
}
}
[HttpGet]
[Route("{layerId:guid}/records/deleted")]
public IActionResult GetDeletedRecords(Guid layerId)
{
try
{
// Get the most recent "Deleted" history entry for each unique RecordId in this layer
var deletedRecords = _db.RecordHistory
.Include(h => h.ChangedBy)
.Where(h => h.LayerId == layerId && h.ChangeType == RecordChangeType.Deleted)
.GroupBy(h => h.RecordId)
.Select(g => g.OrderByDescending(h => h.ChangedAt).FirstOrDefault())
.Where(h => h != null)
.Select(h => new DeletedRecordDto
{
RecordId = h!.RecordId,
Code = h.Code,
Desc1 = h.Desc1,
DeletedAt = h.ChangedAt,
DeletedById = h.ChangedById,
DeletedByName = h.ChangedBy != null ? h.ChangedBy.UserName ?? string.Empty : string.Empty
})
.OrderByDescending(d => d.DeletedAt)
.ToList();
_logger.LogDebug("GetDeletedRecords: Retrieved {Count} deleted records for layer {LayerId}", deletedRecords.Count, layerId);
return Ok(deletedRecords);
}
catch (Exception e)
{
_logger.LogError(e, "GetDeletedRecords: Error retrieving deleted records for layer {LayerId}", layerId);
return BadRequest(e.ToString());
}
}
// Helper method to capture record history
private void CaptureRecordHistory(Record record, RecordChangeType changeType, Guid userId, string? oldCode = null, string? oldDesc1 = null)
{
var changedFields = new List<string>();
var changesSummary = new Dictionary<string, Dictionary<string, string?>>();
if (changeType == RecordChangeType.Updated)
{
if (oldCode != record.Code)
{
changedFields.Add("Code");
changesSummary["Code"] = new Dictionary<string, string?>
{
["old"] = oldCode,
["new"] = record.Code
};
}
if (oldDesc1 != record.Desc1)
{
changedFields.Add("Desc1");
changesSummary["Desc1"] = new Dictionary<string, string?>
{
["old"] = oldDesc1,
["new"] = record.Desc1
};
}
}
var history = new RecordHistory
{
Id = Guid.NewGuid(),
RecordId = record.Id,
LayerId = record.LayerId,
ChangedAt = DateTime.UtcNow,
ChangedById = userId,
ChangeType = changeType,
Code = record.Code,
Desc1 = record.Desc1,
ChangedFields = changedFields.Any() ? string.Join(", ", changedFields) : null,
ChangesSummary = changesSummary.Any() ? JsonSerializer.Serialize(changesSummary) : null
};
_db.RecordHistory.Add(history);
_logger.LogInformation("CaptureRecordHistory: Captured {ChangeType} for record {RecordId}", changeType, record.Id);
}
// Helper method to format history change for display
private static string FormatHistoryChange(RecordHistory h)
{
if (h.ChangeType == RecordChangeType.Created)
{
return $"Created record with Code: \"{h.Code}\", Description: \"{h.Desc1}\"";
}
if (h.ChangeType == RecordChangeType.Deleted)
{
return $"Deleted record Code: \"{h.Code}\", Description: \"{h.Desc1}\"";
}
// Updated
if (!string.IsNullOrEmpty(h.ChangesSummary))
{
try
{
var changes = JsonSerializer.Deserialize<Dictionary<string, Dictionary<string, string?>>>(h.ChangesSummary);
if (changes != null)
{
var parts = new List<string>();
foreach (var (field, values) in changes)
{
var oldVal = values.GetValueOrDefault("old") ?? "empty";
var newVal = values.GetValueOrDefault("new") ?? "empty";
parts.Add($"{field}: \"{oldVal}\" → \"{newVal}\"");
}
return $"Updated: {string.Join(", ", parts)}";
}
}
catch
{
// Fall back to simple message
}
}
return $"Updated {h.ChangedFields ?? "record"}";
}
} }

View File

@@ -0,0 +1,11 @@
namespace DiunaBI.Application.DTOModels;
public class DeletedRecordDto
{
public Guid RecordId { get; set; }
public string Code { get; set; } = string.Empty;
public string? Desc1 { get; set; }
public DateTime DeletedAt { get; set; }
public Guid DeletedById { get; set; }
public string DeletedByName { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,27 @@
namespace DiunaBI.Application.DTOModels;
public class RecordHistoryDto
{
public Guid Id { get; set; }
public Guid RecordId { get; set; }
public Guid LayerId { get; set; }
// When and who
public DateTime ChangedAt { get; set; }
public Guid ChangedById { get; set; }
public string ChangedByName { get; set; } = string.Empty;
// Type of change
public string ChangeType { get; set; } = string.Empty; // "Created", "Updated", "Deleted"
// Snapshot values
public string Code { get; set; } = string.Empty;
public string? Desc1 { get; set; }
// What changed
public string? ChangedFields { get; set; } // "Code, Desc1"
public string? ChangesSummary { get; set; } // JSON: {"Code": {"old": "A", "new": "B"}}
// Formatted display text
public string FormattedChange { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,37 @@
using System;
namespace DiunaBI.Domain.Entities;
public enum RecordChangeType
{
Created = 1,
Updated = 2,
Deleted = 3
}
public class RecordHistory
{
public Guid Id { get; set; }
// Reference to the original record
public Guid RecordId { get; set; }
public Guid LayerId { get; set; }
// When and who
public DateTime ChangedAt { get; set; }
public Guid ChangedById { get; set; }
public User? ChangedBy { get; set; }
// Type of change
public RecordChangeType ChangeType { get; set; }
// Snapshot of record state at this point
public string Code { get; set; } = string.Empty;
public string? Desc1 { get; set; }
// Comma-separated list of fields that changed (e.g., "Code,Desc1")
public string? ChangedFields { get; set; }
// JSON object with detailed changes: {"Code": {"old": "A", "new": "B"}}
public string? ChangesSummary { get; set; }
}

View File

@@ -8,6 +8,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
public DbSet<User> Users { get; init; } public DbSet<User> Users { get; init; }
public DbSet<Layer> Layers { get; init; } public DbSet<Layer> Layers { get; init; }
public DbSet<Record> Records { get; init; } public DbSet<Record> Records { get; init; }
public DbSet<RecordHistory> RecordHistory { get; init; }
public DbSet<ProcessSource> ProcessSources { get; init; } public DbSet<ProcessSource> ProcessSources { get; init; }
public DbSet<DataInbox> DataInbox { get; init; } public DbSet<DataInbox> DataInbox { get; init; }
public DbSet<QueueJob> QueueJobs { get; init; } public DbSet<QueueJob> QueueJobs { get; init; }
@@ -75,6 +76,30 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
.HasForeignKey(x => x.LayerId) .HasForeignKey(x => x.LayerId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<RecordHistory>().HasKey(x => x.Id);
modelBuilder.Entity<RecordHistory>().Property(x => x.RecordId).IsRequired();
modelBuilder.Entity<RecordHistory>().Property(x => x.LayerId).IsRequired();
modelBuilder.Entity<RecordHistory>().Property(x => x.ChangedAt).IsRequired();
modelBuilder.Entity<RecordHistory>().Property(x => x.ChangedById).IsRequired();
modelBuilder.Entity<RecordHistory>().Property(x => x.ChangeType).IsRequired().HasConversion<int>();
modelBuilder.Entity<RecordHistory>().Property(x => x.Code).IsRequired().HasMaxLength(50);
modelBuilder.Entity<RecordHistory>().Property(x => x.Desc1).HasMaxLength(10000);
modelBuilder.Entity<RecordHistory>().Property(x => x.ChangedFields).HasMaxLength(200);
modelBuilder.Entity<RecordHistory>().Property(x => x.ChangesSummary).HasMaxLength(4000);
// Indexes for efficient history queries
modelBuilder.Entity<RecordHistory>()
.HasIndex(x => new { x.RecordId, x.ChangedAt });
modelBuilder.Entity<RecordHistory>()
.HasIndex(x => new { x.LayerId, x.ChangedAt });
modelBuilder.Entity<RecordHistory>()
.HasOne(x => x.ChangedBy)
.WithMany()
.HasForeignKey(x => x.ChangedById)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ProcessSource>().HasKey(x => new { x.LayerId, x.SourceId }); modelBuilder.Entity<ProcessSource>().HasKey(x => new { x.LayerId, x.SourceId });
modelBuilder.Entity<ProcessSource>().Property(x => x.LayerId).IsRequired(); modelBuilder.Entity<ProcessSource>().Property(x => x.LayerId).IsRequired();
modelBuilder.Entity<ProcessSource>().Property(x => x.SourceId).IsRequired(); modelBuilder.Entity<ProcessSource>().Property(x => x.SourceId).IsRequired();

View File

@@ -0,0 +1,490 @@
// <auto-generated />
using System;
using DiunaBI.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DiunaBI.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20251201165810_RecordHistory")]
partial class RecordHistory
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("DiunaBI.Domain.Entities.DataInbox", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<string>("Data")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.HasKey("Id");
b.ToTable("DataInbox");
});
modelBuilder.Entity("DiunaBI.Domain.Entities.Layer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<Guid>("CreatedById")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsCancelled")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<DateTime>("ModifiedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<Guid>("ModifiedById")
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int>("Number")
.HasColumnType("int");
b.Property<Guid?>("ParentId")
.HasColumnType("uniqueidentifier");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CreatedById");
b.HasIndex("ModifiedById");
b.ToTable("Layers");
});
modelBuilder.Entity("DiunaBI.Domain.Entities.ProcessSource", b =>
{
b.Property<Guid>("LayerId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("SourceId")
.HasColumnType("uniqueidentifier");
b.HasKey("LayerId", "SourceId");
b.HasIndex("SourceId");
b.ToTable("ProcessSources");
});
modelBuilder.Entity("DiunaBI.Domain.Entities.QueueJob", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("datetime2");
b.Property<Guid>("CreatedById")
.HasColumnType("uniqueidentifier");
b.Property<int>("JobType")
.HasColumnType("int");
b.Property<DateTime?>("LastAttemptAt")
.HasColumnType("datetime2");
b.Property<string>("LastError")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<Guid>("LayerId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LayerName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("MaxRetries")
.HasColumnType("int");
b.Property<DateTime>("ModifiedAtUtc")
.HasColumnType("datetime2");
b.Property<Guid>("ModifiedById")
.HasColumnType("uniqueidentifier");
b.Property<string>("PluginName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("Priority")
.HasColumnType("int");
b.Property<int>("RetryCount")
.HasColumnType("int");
b.Property<int>("Status")
.HasColumnType("int");
b.HasKey("Id");
b.ToTable("QueueJobs");
});
modelBuilder.Entity("DiunaBI.Domain.Entities.Record", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<Guid>("CreatedById")
.HasColumnType("uniqueidentifier");
b.Property<string>("Desc1")
.HasMaxLength(10000)
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<Guid>("LayerId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("ModifiedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<Guid>("ModifiedById")
.HasColumnType("uniqueidentifier");
b.Property<double?>("Value1")
.HasColumnType("float");
b.Property<double?>("Value10")
.HasColumnType("float");
b.Property<double?>("Value11")
.HasColumnType("float");
b.Property<double?>("Value12")
.HasColumnType("float");
b.Property<double?>("Value13")
.HasColumnType("float");
b.Property<double?>("Value14")
.HasColumnType("float");
b.Property<double?>("Value15")
.HasColumnType("float");
b.Property<double?>("Value16")
.HasColumnType("float");
b.Property<double?>("Value17")
.HasColumnType("float");
b.Property<double?>("Value18")
.HasColumnType("float");
b.Property<double?>("Value19")
.HasColumnType("float");
b.Property<double?>("Value2")
.HasColumnType("float");
b.Property<double?>("Value20")
.HasColumnType("float");
b.Property<double?>("Value21")
.HasColumnType("float");
b.Property<double?>("Value22")
.HasColumnType("float");
b.Property<double?>("Value23")
.HasColumnType("float");
b.Property<double?>("Value24")
.HasColumnType("float");
b.Property<double?>("Value25")
.HasColumnType("float");
b.Property<double?>("Value26")
.HasColumnType("float");
b.Property<double?>("Value27")
.HasColumnType("float");
b.Property<double?>("Value28")
.HasColumnType("float");
b.Property<double?>("Value29")
.HasColumnType("float");
b.Property<double?>("Value3")
.HasColumnType("float");
b.Property<double?>("Value30")
.HasColumnType("float");
b.Property<double?>("Value31")
.HasColumnType("float");
b.Property<double?>("Value32")
.HasColumnType("float");
b.Property<double?>("Value4")
.HasColumnType("float");
b.Property<double?>("Value5")
.HasColumnType("float");
b.Property<double?>("Value6")
.HasColumnType("float");
b.Property<double?>("Value7")
.HasColumnType("float");
b.Property<double?>("Value8")
.HasColumnType("float");
b.Property<double?>("Value9")
.HasColumnType("float");
b.HasKey("Id");
b.HasIndex("CreatedById");
b.HasIndex("LayerId");
b.HasIndex("ModifiedById");
b.ToTable("Records");
});
modelBuilder.Entity("DiunaBI.Domain.Entities.RecordHistory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("ChangeType")
.HasColumnType("int");
b.Property<DateTime>("ChangedAt")
.HasColumnType("datetime2");
b.Property<Guid>("ChangedById")
.HasColumnType("uniqueidentifier");
b.Property<string>("ChangedFields")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("ChangesSummary")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Desc1")
.HasMaxLength(10000)
.HasColumnType("nvarchar(max)");
b.Property<Guid>("LayerId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("RecordId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ChangedById");
b.HasIndex("LayerId", "ChangedAt");
b.HasIndex("RecordId", "ChangedAt");
b.ToTable("RecordHistory");
});
modelBuilder.Entity("DiunaBI.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<string>("Email")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("UserName")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("DiunaBI.Domain.Entities.Layer", b =>
{
b.HasOne("DiunaBI.Domain.Entities.User", "CreatedBy")
.WithMany()
.HasForeignKey("CreatedById")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("DiunaBI.Domain.Entities.User", "ModifiedBy")
.WithMany()
.HasForeignKey("ModifiedById")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CreatedBy");
b.Navigation("ModifiedBy");
});
modelBuilder.Entity("DiunaBI.Domain.Entities.ProcessSource", b =>
{
b.HasOne("DiunaBI.Domain.Entities.Layer", null)
.WithMany()
.HasForeignKey("LayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DiunaBI.Domain.Entities.Layer", "Source")
.WithMany()
.HasForeignKey("SourceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Source");
});
modelBuilder.Entity("DiunaBI.Domain.Entities.Record", b =>
{
b.HasOne("DiunaBI.Domain.Entities.User", "CreatedBy")
.WithMany()
.HasForeignKey("CreatedById")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("DiunaBI.Domain.Entities.Layer", null)
.WithMany("Records")
.HasForeignKey("LayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DiunaBI.Domain.Entities.User", "ModifiedBy")
.WithMany()
.HasForeignKey("ModifiedById")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CreatedBy");
b.Navigation("ModifiedBy");
});
modelBuilder.Entity("DiunaBI.Domain.Entities.RecordHistory", b =>
{
b.HasOne("DiunaBI.Domain.Entities.User", "ChangedBy")
.WithMany()
.HasForeignKey("ChangedById")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("ChangedBy");
});
modelBuilder.Entity("DiunaBI.Domain.Entities.Layer", b =>
{
b.Navigation("Records");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,63 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DiunaBI.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class RecordHistory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "RecordHistory",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RecordId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LayerId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ChangedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
ChangedById = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ChangeType = table.Column<int>(type: "int", nullable: false),
Code = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Desc1 = table.Column<string>(type: "nvarchar(max)", maxLength: 10000, nullable: true),
ChangedFields = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
ChangesSummary = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RecordHistory", x => x.Id);
table.ForeignKey(
name: "FK_RecordHistory_Users_ChangedById",
column: x => x.ChangedById,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_RecordHistory_ChangedById",
table: "RecordHistory",
column: "ChangedById");
migrationBuilder.CreateIndex(
name: "IX_RecordHistory_LayerId_ChangedAt",
table: "RecordHistory",
columns: new[] { "LayerId", "ChangedAt" });
migrationBuilder.CreateIndex(
name: "IX_RecordHistory_RecordId_ChangedAt",
table: "RecordHistory",
columns: new[] { "RecordId", "ChangedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RecordHistory");
}
}
}

View File

@@ -332,6 +332,55 @@ namespace DiunaBI.Infrastructure.Migrations
b.ToTable("Records"); b.ToTable("Records");
}); });
modelBuilder.Entity("DiunaBI.Domain.Entities.RecordHistory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("ChangeType")
.HasColumnType("int");
b.Property<DateTime>("ChangedAt")
.HasColumnType("datetime2");
b.Property<Guid>("ChangedById")
.HasColumnType("uniqueidentifier");
b.Property<string>("ChangedFields")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("ChangesSummary")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Desc1")
.HasMaxLength(10000)
.HasColumnType("nvarchar(max)");
b.Property<Guid>("LayerId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("RecordId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ChangedById");
b.HasIndex("LayerId", "ChangedAt");
b.HasIndex("RecordId", "ChangedAt");
b.ToTable("RecordHistory");
});
modelBuilder.Entity("DiunaBI.Domain.Entities.User", b => modelBuilder.Entity("DiunaBI.Domain.Entities.User", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -417,6 +466,17 @@ namespace DiunaBI.Infrastructure.Migrations
b.Navigation("ModifiedBy"); b.Navigation("ModifiedBy");
}); });
modelBuilder.Entity("DiunaBI.Domain.Entities.RecordHistory", b =>
{
b.HasOne("DiunaBI.Domain.Entities.User", "ChangedBy")
.WithMany()
.HasForeignKey("ChangedById")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("ChangedBy");
});
modelBuilder.Entity("DiunaBI.Domain.Entities.Layer", b => modelBuilder.Entity("DiunaBI.Domain.Entities.Layer", b =>
{ {
b.Navigation("Records"); b.Navigation("Records");

View File

@@ -72,6 +72,9 @@
<MudDivider Class="my-4"/> <MudDivider Class="my-4"/>
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pt-4">
<MudTabPanel Text="Details" Icon="@Icons.Material.Filled.TableChart">
<MudTable Items="@records" <MudTable Items="@records"
Dense="true" Dense="true"
Striped="true" Striped="true"
@@ -218,6 +221,133 @@
</MudButton> </MudButton>
} }
} }
</MudTabPanel>
<MudTabPanel Text="History" Icon="@Icons.Material.Filled.History">
@if (isLoadingHistory)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" />
}
else if (selectedRecordForHistory != null || selectedDeletedRecordForHistory != null)
{
<MudPaper Class="pa-4 mb-4" Outlined="true">
<MudText Typo="Typo.h6">
History for Record:
@if (selectedDeletedRecordForHistory != null)
{
<MudText Typo="Typo.h6" Inline="true" Color="Color.Error">@selectedDeletedRecordForHistory.Code (Deleted)</MudText>
}
else
{
@selectedRecordForHistory?.Code
}
</MudText>
<MudButton Variant="Variant.Text"
Color="Color.Primary"
OnClick="ClearHistorySelection"
StartIcon="@Icons.Material.Filled.ArrowBack"
Size="Size.Small">
Back to list
</MudButton>
</MudPaper>
@if (!recordHistory.Any())
{
<MudAlert Severity="Severity.Info">No history available for this record.</MudAlert>
}
else
{
<MudTimeline TimelineOrientation="TimelineOrientation.Vertical" TimelinePosition="TimelinePosition.Start">
@foreach (var history in recordHistory)
{
<MudTimelineItem Color="@GetHistoryColor(history.ChangeType)" Size="Size.Small">
<ItemOpposite>
<MudText Color="Color.Default" Typo="Typo.body2">
@history.ChangedAt.ToString("g")
</MudText>
</ItemOpposite>
<ItemContent>
<MudPaper Elevation="3" Class="pa-3">
<MudText Typo="Typo.body1">
<strong>@history.ChangeType</strong> by @history.ChangedByName
</MudText>
<MudText Typo="Typo.body2" Class="mt-2">
@history.FormattedChange
</MudText>
</MudPaper>
</ItemContent>
</MudTimelineItem>
}
</MudTimeline>
}
}
else
{
<MudText Typo="Typo.h6" Class="mb-3">Active Records</MudText>
<MudText Typo="Typo.body2" Class="mb-2">Select a record to view its history:</MudText>
<MudTable Items="@records"
Dense="true"
Striped="true"
Hover="true"
FixedHeader="true"
Height="300px"
OnRowClick="@((TableRowClickEventArgs<RecordDto> args) => OnRecordClickForHistory(args))"
T="RecordDto"
Style="cursor: pointer;">
<HeaderContent>
<MudTh>Code</MudTh>
<MudTh>Description</MudTh>
<MudTh>Modified</MudTh>
<MudTh>Modified By</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Code">@context.Code</MudTd>
<MudTd DataLabel="Description">@context.Desc1</MudTd>
<MudTd DataLabel="Modified">@context.ModifiedAt.ToString("g")</MudTd>
<MudTd DataLabel="Modified By">@GetModifiedByUsername(context.ModifiedById)</MudTd>
</RowTemplate>
</MudTable>
<MudDivider Class="my-4"/>
<MudText Typo="Typo.h6" Class="mb-3">Deleted Records</MudText>
<MudText Typo="Typo.body2" Class="mb-2">Select a deleted record to view its history:</MudText>
@if (deletedRecords.Any())
{
<MudTable Items="@deletedRecords"
Dense="true"
Striped="true"
Hover="true"
FixedHeader="true"
Height="200px"
OnRowClick="@((TableRowClickEventArgs<DeletedRecordDto> args) => OnDeletedRecordClickForHistory(args))"
T="DeletedRecordDto"
Style="cursor: pointer;">
<HeaderContent>
<MudTh>Code</MudTh>
<MudTh>Description</MudTh>
<MudTh>Deleted</MudTh>
<MudTh>Deleted By</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Code">
<MudText Color="Color.Error">@context.Code</MudText>
</MudTd>
<MudTd DataLabel="Description">@context.Desc1</MudTd>
<MudTd DataLabel="Deleted">@context.DeletedAt.ToString("g")</MudTd>
<MudTd DataLabel="Deleted By">@context.DeletedByName</MudTd>
</RowTemplate>
</MudTable>
}
else
{
<MudAlert Severity="Severity.Info" Dense="true">No deleted records found.</MudAlert>
}
}
</MudTabPanel>
</MudTabs>
} }
</MudCardContent> </MudCardContent>
</MudCard> </MudCard>

View File

@@ -27,6 +27,15 @@ public partial class LayerDetailPage : ComponentBase
private RecordDto newRecord = new(); private RecordDto newRecord = new();
private bool isEditable => layer?.Type == LayerType.Dictionary || layer?.Type == LayerType.Administration; private bool isEditable => layer?.Type == LayerType.Dictionary || layer?.Type == LayerType.Administration;
// History tab state
private bool isLoadingHistory = false;
private bool isHistoryTabInitialized = false;
private RecordDto? selectedRecordForHistory = null;
private DeletedRecordDto? selectedDeletedRecordForHistory = null;
private List<RecordHistoryDto> recordHistory = new();
private List<DeletedRecordDto> deletedRecords = new();
private Dictionary<Guid, string> userCache = new();
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await LoadLayer(); await LoadLayer();
@@ -37,6 +46,16 @@ public partial class LayerDetailPage : ComponentBase
await LoadLayer(); await LoadLayer();
} }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!isHistoryTabInitialized && !isLoadingHistory &&
selectedRecordForHistory == null && selectedDeletedRecordForHistory == null &&
deletedRecords.Count == 0)
{
await LoadDeletedRecordsAsync();
}
}
private async Task LoadLayer() private async Task LoadLayer()
{ {
isLoading = true; isLoading = true;
@@ -51,6 +70,7 @@ public partial class LayerDetailPage : ComponentBase
records = layer.Records; records = layer.Records;
CalculateDisplayedColumns(); CalculateDisplayedColumns();
CalculateValueSum(); CalculateValueSum();
BuildUserCache();
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -278,4 +298,114 @@ public partial class LayerDetailPage : ComponentBase
Snackbar.Add("Error adding record", Severity.Error); Snackbar.Add("Error adding record", Severity.Error);
} }
} }
// History tab methods
private async Task LoadDeletedRecordsAsync()
{
if (isHistoryTabInitialized || layer == null) return;
isHistoryTabInitialized = true;
try
{
Console.WriteLine($"Loading deleted records for layer {layer.Id}");
deletedRecords = await LayerService.GetDeletedRecordsAsync(layer.Id);
Console.WriteLine($"Loaded {deletedRecords.Count} deleted records");
StateHasChanged();
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error loading deleted records: {ex.Message}");
Console.Error.WriteLine($"Stack trace: {ex.StackTrace}");
deletedRecords = new List<DeletedRecordDto>();
}
}
private async Task OnRecordClickForHistory(TableRowClickEventArgs<RecordDto> args)
{
if (args.Item == null || layer == null) return;
selectedRecordForHistory = args.Item;
isLoadingHistory = true;
StateHasChanged();
try
{
recordHistory = await LayerService.GetRecordHistoryAsync(layer.Id, args.Item.Id);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error loading record history: {ex.Message}");
Snackbar.Add("Error loading record history", Severity.Error);
recordHistory = new List<RecordHistoryDto>();
}
finally
{
isLoadingHistory = false;
StateHasChanged();
}
}
private void ClearHistorySelection()
{
selectedRecordForHistory = null;
selectedDeletedRecordForHistory = null;
recordHistory.Clear();
isHistoryTabInitialized = false; // Reset so deleted records reload when returning to list
}
private async Task OnDeletedRecordClickForHistory(TableRowClickEventArgs<DeletedRecordDto> args)
{
if (args.Item == null || layer == null) return;
selectedDeletedRecordForHistory = args.Item;
selectedRecordForHistory = null;
isLoadingHistory = true;
StateHasChanged();
try
{
recordHistory = await LayerService.GetRecordHistoryAsync(layer.Id, args.Item.RecordId);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error loading deleted record history: {ex.Message}");
Snackbar.Add("Error loading record history", Severity.Error);
recordHistory = new List<RecordHistoryDto>();
}
finally
{
isLoadingHistory = false;
StateHasChanged();
}
}
private Color GetHistoryColor(string changeType)
{
return changeType switch
{
"Created" => Color.Success,
"Updated" => Color.Info,
"Deleted" => Color.Error,
_ => Color.Default
};
}
private void BuildUserCache()
{
userCache.Clear();
if (layer == null) return;
// Add layer-level users to cache
if (layer.CreatedBy != null)
userCache.TryAdd(layer.CreatedBy.Id, layer.CreatedBy.Username ?? string.Empty);
if (layer.ModifiedBy != null)
userCache.TryAdd(layer.ModifiedBy.Id, layer.ModifiedBy.Username ?? string.Empty);
}
private string GetModifiedByUsername(Guid userId)
{
return userCache.TryGetValue(userId, out var username) ? username : string.Empty;
}
} }

View File

@@ -90,4 +90,32 @@ public class LayerService
var response = await _httpClient.DeleteAsync($"Layers/{layerId}/records/{recordId}"); var response = await _httpClient.DeleteAsync($"Layers/{layerId}/records/{recordId}");
return response.IsSuccessStatusCode; return response.IsSuccessStatusCode;
} }
public async Task<List<RecordHistoryDto>> GetRecordHistoryAsync(Guid layerId, Guid recordId)
{
var response = await _httpClient.GetAsync($"Layers/{layerId}/records/{recordId}/history");
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync();
Console.Error.WriteLine($"GetRecordHistoryAsync failed: {response.StatusCode} - {error}");
return new List<RecordHistoryDto>();
}
return await response.Content.ReadFromJsonAsync<List<RecordHistoryDto>>() ?? new List<RecordHistoryDto>();
}
public async Task<List<DeletedRecordDto>> GetDeletedRecordsAsync(Guid layerId)
{
var response = await _httpClient.GetAsync($"Layers/{layerId}/records/deleted");
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync();
Console.Error.WriteLine($"GetDeletedRecordsAsync failed: {response.StatusCode} - {error}");
return new List<DeletedRecordDto>();
}
return await response.Content.ReadFromJsonAsync<List<DeletedRecordDto>>() ?? new List<DeletedRecordDto>();
}
} }