Compare commits
5 Commits
7ea5ed506e
...
f68e57ce3b
| Author | SHA1 | Date | |
|---|---|---|---|
| f68e57ce3b | |||
| e70a8dda6e | |||
| 89859cd4a3 | |||
| 0c6848556b | |||
| c8ded1f0a4 |
@@ -1,9 +1,11 @@
|
||||
using DiunaBI.API.Services;
|
||||
using DiunaBI.Domain.Entities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DiunaBI.API.Controllers;
|
||||
|
||||
[AllowAnonymous]
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
public class AuthController(
|
||||
|
||||
@@ -9,6 +9,7 @@ using DiunaBI.Application.DTOModels.Common;
|
||||
|
||||
namespace DiunaBI.API.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
public class DataInboxController : Controller
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Google.Apis.Sheets.v4;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,6 +13,7 @@ using DiunaBI.Infrastructure.Services;
|
||||
|
||||
namespace DiunaBI.API.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
public class LayersController : Controller
|
||||
@@ -727,4 +729,398 @@ public class LayersController : Controller
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Record CRUD operations
|
||||
[HttpPost]
|
||||
[Route("{layerId:guid}/records")]
|
||||
public IActionResult CreateRecord(Guid layerId, [FromBody] RecordDto recordDto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = Request.Headers["UserId"].ToString();
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
_logger.LogWarning("CreateRecord: No UserId in request headers");
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var layer = _db.Layers.FirstOrDefault(x => x.Id == layerId && !x.IsDeleted);
|
||||
if (layer == null)
|
||||
{
|
||||
_logger.LogWarning("CreateRecord: Layer {LayerId} not found", layerId);
|
||||
return NotFound("Layer not found");
|
||||
}
|
||||
|
||||
if (layer.Type != Domain.Entities.LayerType.Dictionary && layer.Type != Domain.Entities.LayerType.Administration)
|
||||
{
|
||||
_logger.LogWarning("CreateRecord: Layer {LayerId} is not editable (type: {LayerType})", layerId, layer.Type);
|
||||
return BadRequest("Only Dictionary and Administration layers can be edited");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(recordDto.Code))
|
||||
{
|
||||
return BadRequest("Code is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(recordDto.Desc1))
|
||||
{
|
||||
return BadRequest("Desc1 is required");
|
||||
}
|
||||
|
||||
var record = new Record
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = recordDto.Code,
|
||||
Desc1 = recordDto.Desc1,
|
||||
LayerId = layerId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
ModifiedAt = DateTime.UtcNow,
|
||||
CreatedById = Guid.Parse(userId),
|
||||
ModifiedById = Guid.Parse(userId),
|
||||
IsDeleted = false
|
||||
};
|
||||
|
||||
_db.Records.Add(record);
|
||||
|
||||
// Capture history
|
||||
CaptureRecordHistory(record, RecordChangeType.Created, Guid.Parse(userId));
|
||||
|
||||
// Update layer modified info
|
||||
layer.ModifiedAt = DateTime.UtcNow;
|
||||
layer.ModifiedById = Guid.Parse(userId);
|
||||
|
||||
_db.SaveChanges();
|
||||
|
||||
_logger.LogInformation("CreateRecord: Created record {RecordId} in layer {LayerId}", record.Id, layerId);
|
||||
|
||||
return Ok(new RecordDto
|
||||
{
|
||||
Id = record.Id,
|
||||
Code = record.Code,
|
||||
Desc1 = record.Desc1,
|
||||
LayerId = record.LayerId,
|
||||
CreatedAt = record.CreatedAt,
|
||||
ModifiedAt = record.ModifiedAt,
|
||||
CreatedById = record.CreatedById,
|
||||
ModifiedById = record.ModifiedById
|
||||
});
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "CreateRecord: Error creating record in layer {LayerId}", layerId);
|
||||
return BadRequest(e.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[Route("{layerId:guid}/records/{recordId:guid}")]
|
||||
public IActionResult UpdateRecord(Guid layerId, Guid recordId, [FromBody] RecordDto recordDto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = Request.Headers["UserId"].ToString();
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
_logger.LogWarning("UpdateRecord: No UserId in request headers");
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var layer = _db.Layers.FirstOrDefault(x => x.Id == layerId && !x.IsDeleted);
|
||||
if (layer == null)
|
||||
{
|
||||
_logger.LogWarning("UpdateRecord: Layer {LayerId} not found", layerId);
|
||||
return NotFound("Layer not found");
|
||||
}
|
||||
|
||||
if (layer.Type != Domain.Entities.LayerType.Dictionary && layer.Type != Domain.Entities.LayerType.Administration)
|
||||
{
|
||||
_logger.LogWarning("UpdateRecord: Layer {LayerId} is not editable (type: {LayerType})", layerId, layer.Type);
|
||||
return BadRequest("Only Dictionary and Administration layers can be edited");
|
||||
}
|
||||
|
||||
var record = _db.Records.FirstOrDefault(x => x.Id == recordId && x.LayerId == layerId);
|
||||
if (record == null)
|
||||
{
|
||||
_logger.LogWarning("UpdateRecord: Record {RecordId} not found in layer {LayerId}", recordId, layerId);
|
||||
return NotFound("Record not found");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(recordDto.Code))
|
||||
{
|
||||
return BadRequest("Code is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(recordDto.Desc1))
|
||||
{
|
||||
return BadRequest("Desc1 is required");
|
||||
}
|
||||
|
||||
// Capture old values before updating
|
||||
var oldCode = record.Code;
|
||||
var oldDesc1 = record.Desc1;
|
||||
|
||||
record.Desc1 = recordDto.Desc1;
|
||||
record.ModifiedAt = DateTime.UtcNow;
|
||||
record.ModifiedById = Guid.Parse(userId);
|
||||
|
||||
// Capture history
|
||||
CaptureRecordHistory(record, RecordChangeType.Updated, Guid.Parse(userId), oldCode, oldDesc1);
|
||||
|
||||
// Update layer modified info
|
||||
layer.ModifiedAt = DateTime.UtcNow;
|
||||
layer.ModifiedById = Guid.Parse(userId);
|
||||
|
||||
_db.SaveChanges();
|
||||
|
||||
_logger.LogInformation("UpdateRecord: Updated record {RecordId} in layer {LayerId}", recordId, layerId);
|
||||
|
||||
return Ok(new RecordDto
|
||||
{
|
||||
Id = record.Id,
|
||||
Code = record.Code,
|
||||
Desc1 = record.Desc1,
|
||||
LayerId = record.LayerId,
|
||||
CreatedAt = record.CreatedAt,
|
||||
ModifiedAt = record.ModifiedAt,
|
||||
CreatedById = record.CreatedById,
|
||||
ModifiedById = record.ModifiedById
|
||||
});
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "UpdateRecord: Error updating record {RecordId} in layer {LayerId}", recordId, layerId);
|
||||
return BadRequest(e.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete]
|
||||
[Route("{layerId:guid}/records/{recordId:guid}")]
|
||||
public IActionResult DeleteRecord(Guid layerId, Guid recordId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = Request.Headers["UserId"].ToString();
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
_logger.LogWarning("DeleteRecord: No UserId in request headers");
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var layer = _db.Layers.FirstOrDefault(x => x.Id == layerId && !x.IsDeleted);
|
||||
if (layer == null)
|
||||
{
|
||||
_logger.LogWarning("DeleteRecord: Layer {LayerId} not found", layerId);
|
||||
return NotFound("Layer not found");
|
||||
}
|
||||
|
||||
if (layer.Type != Domain.Entities.LayerType.Dictionary && layer.Type != Domain.Entities.LayerType.Administration)
|
||||
{
|
||||
_logger.LogWarning("DeleteRecord: Layer {LayerId} is not editable (type: {LayerType})", layerId, layer.Type);
|
||||
return BadRequest("Only Dictionary and Administration layers can be edited");
|
||||
}
|
||||
|
||||
var record = _db.Records.FirstOrDefault(x => x.Id == recordId && x.LayerId == layerId);
|
||||
if (record == null)
|
||||
{
|
||||
_logger.LogWarning("DeleteRecord: Record {RecordId} not found in layer {LayerId}", recordId, layerId);
|
||||
return NotFound("Record not found");
|
||||
}
|
||||
|
||||
// Capture history before deleting
|
||||
CaptureRecordHistory(record, RecordChangeType.Deleted, Guid.Parse(userId));
|
||||
|
||||
_db.Records.Remove(record);
|
||||
|
||||
// Update layer modified info
|
||||
layer.ModifiedAt = DateTime.UtcNow;
|
||||
layer.ModifiedById = Guid.Parse(userId);
|
||||
|
||||
_db.SaveChanges();
|
||||
|
||||
_logger.LogInformation("DeleteRecord: Deleted record {RecordId} from layer {LayerId}", recordId, layerId);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "DeleteRecord: Error deleting record {RecordId} from layer {LayerId}", recordId, layerId);
|
||||
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
|
||||
// First, get all deleted record history entries
|
||||
var deletedHistoryEntries = _db.RecordHistory
|
||||
.Where(h => h.LayerId == layerId && h.ChangeType == RecordChangeType.Deleted)
|
||||
.ToList();
|
||||
|
||||
// Group in memory and get the most recent deletion for each record
|
||||
var mostRecentDeletes = deletedHistoryEntries
|
||||
.GroupBy(h => h.RecordId)
|
||||
.Select(g => g.OrderByDescending(h => h.ChangedAt).First())
|
||||
.ToList();
|
||||
|
||||
// Get all unique user IDs from the history entries
|
||||
var userIds = mostRecentDeletes.Select(h => h.ChangedById).Distinct().ToList();
|
||||
|
||||
// Load the users
|
||||
var users = _db.Users
|
||||
.Where(u => userIds.Contains(u.Id))
|
||||
.ToDictionary(u => u.Id, u => u.UserName ?? string.Empty);
|
||||
|
||||
// Build the DTOs
|
||||
var deletedRecords = mostRecentDeletes
|
||||
.Select(h => new DeletedRecordDto
|
||||
{
|
||||
RecordId = h.RecordId,
|
||||
Code = h.Code,
|
||||
Desc1 = h.Desc1,
|
||||
DeletedAt = h.ChangedAt,
|
||||
DeletedById = h.ChangedById,
|
||||
DeletedByName = users.TryGetValue(h.ChangedById, out var userName) ? userName : 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"}";
|
||||
}
|
||||
}
|
||||
@@ -177,26 +177,67 @@ else
|
||||
|
||||
pluginManager.LoadPluginsFromDirectory(pluginsPath);
|
||||
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var token = context.Request.Headers.Authorization.ToString();
|
||||
if (token.Length > 0
|
||||
&& !context.Request.Path.ToString().Contains("getForPowerBI")
|
||||
&& !context.Request.Path.ToString().Contains("getConfiguration")
|
||||
&& !context.Request.Path.ToString().Contains("DataInbox/Add"))
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var data = handler.ReadJwtToken(token.Split(' ')[1]);
|
||||
context.Request.Headers.Append("UserId", new Microsoft.Extensions.Primitives.StringValues(data.Subject));
|
||||
}
|
||||
await next(context);
|
||||
});
|
||||
|
||||
app.UseCors("CORSPolicy");
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Middleware to extract UserId from JWT token AFTER authentication
|
||||
// This must run after UseAuthentication() so the JWT is already validated
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
|
||||
logger.LogInformation("🔍 UserId Extraction Middleware - Path: {Path}, Method: {Method}",
|
||||
context.Request.Path, context.Request.Method);
|
||||
|
||||
var token = context.Request.Headers.Authorization.ToString();
|
||||
logger.LogInformation("🔍 Authorization header: {Token}",
|
||||
string.IsNullOrEmpty(token) ? "NULL/EMPTY" : $"{token[..Math.Min(30, token.Length)]}...");
|
||||
|
||||
if (!string.IsNullOrEmpty(token) && token.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var jwtToken = handler.ReadJwtToken(token.Split(' ')[1]);
|
||||
|
||||
// Try to get UserId from Subject claim first, then fall back to NameIdentifier
|
||||
var userId = jwtToken.Subject;
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
// Try NameIdentifier claim (ClaimTypes.NameIdentifier)
|
||||
var nameIdClaim = jwtToken.Claims.FirstOrDefault(c =>
|
||||
c.Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier" ||
|
||||
c.Type == "nameid");
|
||||
userId = nameIdClaim?.Value;
|
||||
}
|
||||
|
||||
logger.LogInformation("🔍 JWT UserId: {UserId}", userId ?? "NULL");
|
||||
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
// Use indexer to set/replace header value instead of Append
|
||||
context.Request.Headers["UserId"] = userId;
|
||||
logger.LogInformation("✅ Set UserId header to: {UserId}", userId);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("❌ UserId not found in JWT claims");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "❌ Failed to extract UserId from JWT token");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("❌ No valid Bearer token found");
|
||||
}
|
||||
|
||||
await next(context);
|
||||
});
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "OK", timestamp = DateTime.UtcNow }))
|
||||
|
||||
11
DiunaBI.Application/DTOModels/DeletedRecordDto.cs
Normal file
11
DiunaBI.Application/DTOModels/DeletedRecordDto.cs
Normal 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;
|
||||
}
|
||||
27
DiunaBI.Application/DTOModels/RecordHistoryDto.cs
Normal file
27
DiunaBI.Application/DTOModels/RecordHistoryDto.cs
Normal 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;
|
||||
}
|
||||
37
DiunaBI.Domain/Entities/RecordHistory.cs
Normal file
37
DiunaBI.Domain/Entities/RecordHistory.cs
Normal 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; }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
public DbSet<User> Users { get; init; }
|
||||
public DbSet<Layer> Layers { get; init; }
|
||||
public DbSet<Record> Records { get; init; }
|
||||
public DbSet<RecordHistory> RecordHistory { get; init; }
|
||||
public DbSet<ProcessSource> ProcessSources { get; init; }
|
||||
public DbSet<DataInbox> DataInbox { get; init; }
|
||||
public DbSet<QueueJob> QueueJobs { get; init; }
|
||||
@@ -75,6 +76,30 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
.HasForeignKey(x => x.LayerId)
|
||||
.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>().Property(x => x.LayerId).IsRequired();
|
||||
modelBuilder.Entity<ProcessSource>().Property(x => x.SourceId).IsRequired();
|
||||
|
||||
490
DiunaBI.Infrastructure/Migrations/20251201165810_RecordHistory.Designer.cs
generated
Normal file
490
DiunaBI.Infrastructure/Migrations/20251201165810_RecordHistory.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,6 +332,55 @@ namespace DiunaBI.Infrastructure.Migrations
|
||||
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")
|
||||
@@ -417,6 +466,17 @@ namespace DiunaBI.Infrastructure.Migrations
|
||||
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");
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:shared="clr-namespace:DiunaBI.UI.Shared.Components;assembly=DiunaBI.UI.Shared"
|
||||
x:Class="DiunaBI.UI.Mobile.MainPage"
|
||||
Title="DiunaBI">
|
||||
Title="DiunaBI App">
|
||||
|
||||
<BlazorWebView x:Name="BlazorWebView" HostPage="wwwroot/index.html">
|
||||
<BlazorWebView.RootComponents>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Loading="isLoading"
|
||||
LoadingProgressColor="Color.Info"
|
||||
LoadingProgressColor="Color.Primary"
|
||||
OnRowClick="@((TableRowClickEventArgs<DataInboxDto> args) => OnRowClick(args.Item))"
|
||||
T="DataInboxDto"
|
||||
Style="cursor: pointer;">
|
||||
@@ -43,10 +43,10 @@
|
||||
<MudTh>Source</MudTh>
|
||||
<MudTh>Created At</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd DataLabel="Source">@context.Source</MudTd>
|
||||
<MudTd DataLabel="Created At">@context.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")</MudTd>
|
||||
<RowTemplate Context="row">
|
||||
<MudTd DataLabel="Name"><div @oncontextmenu="@(async (e) => await OnRowRightClick(e, row))" @oncontextmenu:preventDefault="true">@row.Name</div></MudTd>
|
||||
<MudTd DataLabel="Source"><div @oncontextmenu="@(async (e) => await OnRowRightClick(e, row))" @oncontextmenu:preventDefault="true">@row.Source</div></MudTd>
|
||||
<MudTd DataLabel="Created At"><div @oncontextmenu="@(async (e) => await OnRowRightClick(e, row))" @oncontextmenu:preventDefault="true">@row.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")</div></MudTd>
|
||||
</RowTemplate>
|
||||
<NoRecordsContent>
|
||||
<MudText>No data inbox items to display</MudText>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using DiunaBI.UI.Shared.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using DiunaBI.Application.DTOModels;
|
||||
using DiunaBI.Application.DTOModels.Common;
|
||||
using MudBlazor;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DiunaBI.UI.Shared.Components;
|
||||
|
||||
@@ -11,6 +13,8 @@ public partial class DataInboxListComponent : ComponentBase
|
||||
[Inject] private DataInboxService DataInboxService { get; set; } = default!;
|
||||
[Inject] private ISnackbar Snackbar { get; set; } = default!;
|
||||
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
|
||||
[Inject] private DataInboxFilterStateService FilterStateService { get; set; } = default!;
|
||||
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
|
||||
|
||||
|
||||
private PagedResult<DataInboxDto> dataInbox = new();
|
||||
@@ -19,6 +23,7 @@ public partial class DataInboxListComponent : ComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
filterRequest = FilterStateService.FilterRequest;
|
||||
await LoadDataInbox();
|
||||
}
|
||||
|
||||
@@ -28,6 +33,7 @@ public partial class DataInboxListComponent : ComponentBase
|
||||
|
||||
try
|
||||
{
|
||||
FilterStateService.UpdateFilter(filterRequest);
|
||||
dataInbox = await DataInboxService.GetDataInboxAsync(filterRequest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -55,6 +61,7 @@ public partial class DataInboxListComponent : ComponentBase
|
||||
private async Task ClearFilters()
|
||||
{
|
||||
filterRequest = new DataInboxFilterRequest();
|
||||
FilterStateService.ClearFilter();
|
||||
await LoadDataInbox();
|
||||
}
|
||||
|
||||
@@ -62,4 +69,10 @@ public partial class DataInboxListComponent : ComponentBase
|
||||
{
|
||||
NavigationManager.NavigateTo($"/datainbox/{dataInboxItem.Id}");
|
||||
}
|
||||
|
||||
private async Task OnRowRightClick(MouseEventArgs e, DataInboxDto dataInboxItem)
|
||||
{
|
||||
var url = NavigationManager.ToAbsoluteUri($"/datainbox/{dataInboxItem.Id}").ToString();
|
||||
await JSRuntime.InvokeVoidAsync("open", url, "_blank");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Loading="isLoading"
|
||||
LoadingProgressColor="Color.Info"
|
||||
LoadingProgressColor="Color.Primary"
|
||||
OnRowClick="@((TableRowClickEventArgs<LayerDto> args) => OnRowClick(args.Item))"
|
||||
T="LayerDto"
|
||||
Style="cursor: pointer;">
|
||||
@@ -55,9 +55,9 @@
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Type</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd DataLabel="Type">@context.Type</MudTd>
|
||||
<RowTemplate Context="row">
|
||||
<MudTd DataLabel="Name"><div @oncontextmenu="@(async (e) => await OnRowRightClick(e, row))" @oncontextmenu:preventDefault="true">@row.Name</div></MudTd>
|
||||
<MudTd DataLabel="Type"><div @oncontextmenu="@(async (e) => await OnRowRightClick(e, row))" @oncontextmenu:preventDefault="true">@row.Type</div></MudTd>
|
||||
</RowTemplate>
|
||||
<NoRecordsContent>
|
||||
<MudText>No layers to display</MudText>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using DiunaBI.UI.Shared.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using DiunaBI.Application.DTOModels;
|
||||
using DiunaBI.Application.DTOModels.Common;
|
||||
using MudBlazor;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DiunaBI.UI.Shared.Components;
|
||||
|
||||
@@ -11,14 +13,17 @@ public partial class LayerListComponent : ComponentBase
|
||||
[Inject] private LayerService LayerService { get; set; } = default!;
|
||||
[Inject] private ISnackbar Snackbar { get; set; } = default!;
|
||||
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
|
||||
[Inject] private LayerFilterStateService FilterStateService { get; set; } = default!;
|
||||
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
|
||||
|
||||
|
||||
private PagedResult<LayerDto> layers = new();
|
||||
private LayerFilterRequest filterRequest = new();
|
||||
private bool isLoading = false;
|
||||
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
filterRequest = FilterStateService.FilterRequest;
|
||||
await LoadLayers();
|
||||
}
|
||||
|
||||
@@ -28,6 +33,7 @@ public partial class LayerListComponent : ComponentBase
|
||||
|
||||
try
|
||||
{
|
||||
FilterStateService.UpdateFilter(filterRequest);
|
||||
layers = await LayerService.GetLayersAsync(filterRequest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -55,6 +61,7 @@ public partial class LayerListComponent : ComponentBase
|
||||
private async Task ClearFilters()
|
||||
{
|
||||
filterRequest = new LayerFilterRequest();
|
||||
FilterStateService.ClearFilter();
|
||||
await LoadLayers();
|
||||
}
|
||||
|
||||
@@ -76,4 +83,10 @@ public partial class LayerListComponent : ComponentBase
|
||||
{
|
||||
NavigationManager.NavigateTo($"/layers/{layer.Id}");
|
||||
}
|
||||
|
||||
private async Task OnRowRightClick(MouseEventArgs e, LayerDto layer)
|
||||
{
|
||||
var url = NavigationManager.ToAbsoluteUri($"/layers/{layer.Id}").ToString();
|
||||
await JSRuntime.InvokeVoidAsync("open", url, "_blank");
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddTransient<HttpLoggingHandler>();
|
||||
|
||||
// Configure named HttpClient with logging handler
|
||||
// Note: Authentication is handled by AuthService setting DefaultRequestHeaders.Authorization
|
||||
services.AddHttpClient("DiunaBI", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(baseUri);
|
||||
@@ -38,6 +39,10 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<LayerService>();
|
||||
services.AddScoped<DataInboxService>();
|
||||
|
||||
// Filter state services (scoped to maintain state during user session)
|
||||
services.AddScoped<LayerFilterStateService>();
|
||||
services.AddScoped<DataInboxFilterStateService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
35
DiunaBI.UI.Shared/Handlers/AuthenticationHandler.cs
Normal file
35
DiunaBI.UI.Shared/Handlers/AuthenticationHandler.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using DiunaBI.UI.Shared.Services;
|
||||
|
||||
namespace DiunaBI.UI.Shared.Handlers;
|
||||
|
||||
public class AuthenticationHandler : DelegatingHandler
|
||||
{
|
||||
private readonly TokenProvider _tokenProvider;
|
||||
|
||||
public AuthenticationHandler(TokenProvider tokenProvider)
|
||||
{
|
||||
_tokenProvider = tokenProvider;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Get token from TokenProvider
|
||||
var token = _tokenProvider.Token;
|
||||
|
||||
Console.WriteLine($"🔐 AuthenticationHandler: Token = {(string.IsNullOrEmpty(token) ? "NULL" : $"{token[..Math.Min(20, token.Length)]}...")}");
|
||||
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
Console.WriteLine($"🔐 AuthenticationHandler: Added Bearer token to request");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"🔐 AuthenticationHandler: No token available, request will be unauthorized");
|
||||
}
|
||||
|
||||
return await base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
<MudText Typo="Typo.h5">Layer Details</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<!--
|
||||
<MudButton Variant="Variant.Text" OnClick="Export">Export</MudButton>
|
||||
@if (layer != null && layer.Type == LayerType.Administration)
|
||||
{
|
||||
@@ -21,6 +22,8 @@
|
||||
{
|
||||
<MudButton Variant="Variant.Text" OnClick="ProcessLayer">Process Layer</MudButton>
|
||||
}
|
||||
-->
|
||||
<MudButton Variant="Variant.Text" OnClick="GoBack" StartIcon="@Icons.Material.Filled.ArrowBack">Back to List</MudButton>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
@@ -72,6 +75,9 @@
|
||||
|
||||
<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"
|
||||
Dense="true"
|
||||
Striped="true"
|
||||
@@ -84,12 +90,71 @@
|
||||
{
|
||||
<MudTh>@column</MudTh>
|
||||
}
|
||||
@if (isEditable)
|
||||
{
|
||||
<MudTh>Actions</MudTh>
|
||||
}
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Code">@context.Code</MudTd>
|
||||
@foreach (var column in displayedColumns)
|
||||
@if (editingRecordId == context.Id)
|
||||
{
|
||||
<MudTd DataLabel="@column">@GetRecordValue(context, column)</MudTd>
|
||||
<MudTd DataLabel="Code">
|
||||
<MudTextField @bind-Value="editingRecord.Code"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
FullWidth="true"/>
|
||||
</MudTd>
|
||||
@foreach (var column in displayedColumns)
|
||||
{
|
||||
@if (column == "Description1")
|
||||
{
|
||||
<MudTd DataLabel="@column">
|
||||
<MudTextField @bind-Value="editingRecord.Desc1"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
FullWidth="true"/>
|
||||
</MudTd>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTd DataLabel="@column">@GetRecordValue(context, column)</MudTd>
|
||||
}
|
||||
}
|
||||
<MudTd>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Check"
|
||||
Color="Color.Success"
|
||||
Size="Size.Small"
|
||||
OnClick="SaveEdit"
|
||||
Title="Save"/>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Close"
|
||||
Color="Color.Default"
|
||||
Size="Size.Small"
|
||||
OnClick="CancelEdit"
|
||||
Title="Cancel"/>
|
||||
</MudTd>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTd DataLabel="Code">@context.Code</MudTd>
|
||||
@foreach (var column in displayedColumns)
|
||||
{
|
||||
<MudTd DataLabel="@column">@GetRecordValue(context, column)</MudTd>
|
||||
}
|
||||
@if (isEditable)
|
||||
{
|
||||
<MudTd>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
OnClick="() => StartEdit(context)"
|
||||
Title="Edit"/>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
OnClick="() => DeleteRecord(context)"
|
||||
Title="Delete"/>
|
||||
</MudTd>
|
||||
}
|
||||
}
|
||||
</RowTemplate>
|
||||
<FooterContent>
|
||||
@@ -105,8 +170,187 @@
|
||||
<MudTd></MudTd>
|
||||
}
|
||||
}
|
||||
@if (isEditable)
|
||||
{
|
||||
<MudTd></MudTd>
|
||||
}
|
||||
</FooterContent>
|
||||
</MudTable>
|
||||
|
||||
@if (isEditable)
|
||||
{
|
||||
@if (isAddingNew)
|
||||
{
|
||||
<MudPaper Class="mt-4 pa-4" Outlined="true">
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudTextField @bind-Value="newRecord.Code"
|
||||
Label="Code"
|
||||
Variant="Variant.Outlined"
|
||||
FullWidth="true"/>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudTextField @bind-Value="newRecord.Desc1"
|
||||
Label="Description"
|
||||
Variant="Variant.Outlined"
|
||||
FullWidth="true"/>
|
||||
</MudItem>
|
||||
<MudItem xs="12" Class="d-flex justify-end">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Success"
|
||||
OnClick="SaveNewRecord"
|
||||
StartIcon="@Icons.Material.Filled.Check"
|
||||
Class="mr-2">
|
||||
Save
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Default"
|
||||
OnClick="CancelAddNew"
|
||||
StartIcon="@Icons.Material.Filled.Close">
|
||||
Cancel
|
||||
</MudButton>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
OnClick="StartAddNew"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
Class="mt-4">
|
||||
Add New Record
|
||||
</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>
|
||||
</MudCard>
|
||||
|
||||
@@ -13,11 +13,28 @@ public partial class LayerDetailPage : ComponentBase
|
||||
[Inject]
|
||||
private ISnackbar Snackbar { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; set; } = null!;
|
||||
|
||||
private LayerDto? layer;
|
||||
private List<RecordDto> records = new();
|
||||
private List<string> displayedColumns = new();
|
||||
private double valueSum = 0;
|
||||
private bool isLoading = false;
|
||||
private Guid? editingRecordId = null;
|
||||
private RecordDto? editingRecord = null;
|
||||
private bool isAddingNew = false;
|
||||
private RecordDto newRecord = new();
|
||||
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()
|
||||
{
|
||||
@@ -29,6 +46,16 @@ public partial class LayerDetailPage : ComponentBase
|
||||
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()
|
||||
{
|
||||
isLoading = true;
|
||||
@@ -43,6 +70,7 @@ public partial class LayerDetailPage : ComponentBase
|
||||
records = layer.Records;
|
||||
CalculateDisplayedColumns();
|
||||
CalculateValueSum();
|
||||
BuildUserCache();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -119,4 +147,270 @@ public partial class LayerDetailPage : ComponentBase
|
||||
// TODO: Implement process layer functionality
|
||||
Snackbar.Add("Process layer functionality coming soon", Severity.Error);
|
||||
}
|
||||
|
||||
private void GoBack()
|
||||
{
|
||||
NavigationManager.NavigateTo("/layers");
|
||||
}
|
||||
|
||||
// Record editing methods
|
||||
private void StartEdit(RecordDto record)
|
||||
{
|
||||
editingRecordId = record.Id;
|
||||
editingRecord = new RecordDto
|
||||
{
|
||||
Id = record.Id,
|
||||
Code = record.Code,
|
||||
Desc1 = record.Desc1,
|
||||
LayerId = record.LayerId
|
||||
};
|
||||
}
|
||||
|
||||
private void CancelEdit()
|
||||
{
|
||||
editingRecordId = null;
|
||||
editingRecord = null;
|
||||
}
|
||||
|
||||
private async Task SaveEdit()
|
||||
{
|
||||
if (editingRecord == null || layer == null) return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(editingRecord.Code))
|
||||
{
|
||||
Snackbar.Add("Code is required", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(editingRecord.Desc1))
|
||||
{
|
||||
Snackbar.Add("Description is required", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await LayerService.UpdateRecordAsync(layer.Id, editingRecord.Id, editingRecord);
|
||||
if (updated != null)
|
||||
{
|
||||
var record = records.FirstOrDefault(r => r.Id == editingRecord.Id);
|
||||
if (record != null)
|
||||
{
|
||||
record.Code = updated.Code;
|
||||
record.Desc1 = updated.Desc1;
|
||||
record.ModifiedAt = updated.ModifiedAt;
|
||||
}
|
||||
editingRecordId = null;
|
||||
editingRecord = null;
|
||||
Snackbar.Add("Record updated successfully", Severity.Success);
|
||||
StateHasChanged();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Failed to update record", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error updating record: {ex.Message}");
|
||||
Snackbar.Add("Error updating record", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteRecord(RecordDto record)
|
||||
{
|
||||
if (layer == null) return;
|
||||
|
||||
var result = await DialogService.ShowMessageBox(
|
||||
"Confirm Delete",
|
||||
$"Are you sure you want to delete record '{record.Code}'?",
|
||||
yesText: "Delete",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (result == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var success = await LayerService.DeleteRecordAsync(layer.Id, record.Id);
|
||||
if (success)
|
||||
{
|
||||
records.Remove(record);
|
||||
CalculateDisplayedColumns();
|
||||
CalculateValueSum();
|
||||
Snackbar.Add("Record deleted successfully", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Failed to delete record", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error deleting record: {ex.Message}");
|
||||
Snackbar.Add("Error deleting record", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StartAddNew()
|
||||
{
|
||||
isAddingNew = true;
|
||||
newRecord = new RecordDto { LayerId = layer?.Id ?? Guid.Empty };
|
||||
}
|
||||
|
||||
private void CancelAddNew()
|
||||
{
|
||||
isAddingNew = false;
|
||||
newRecord = new();
|
||||
}
|
||||
|
||||
private async Task SaveNewRecord()
|
||||
{
|
||||
if (layer == null) return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(newRecord.Code))
|
||||
{
|
||||
Snackbar.Add("Code is required", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(newRecord.Desc1))
|
||||
{
|
||||
Snackbar.Add("Description is required", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var created = await LayerService.CreateRecordAsync(layer.Id, newRecord);
|
||||
if (created != null)
|
||||
{
|
||||
records.Add(created);
|
||||
CalculateDisplayedColumns();
|
||||
CalculateValueSum();
|
||||
isAddingNew = false;
|
||||
newRecord = new();
|
||||
Snackbar.Add("Record added successfully", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Failed to add record", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error adding record: {ex.Message}");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ public class AuthService
|
||||
private bool? _isAuthenticated;
|
||||
private UserInfo? _userInfo = null;
|
||||
private string? _apiToken;
|
||||
|
||||
|
||||
public event Action<bool>? AuthenticationStateChanged;
|
||||
|
||||
|
||||
public AuthService(HttpClient httpClient, IJSRuntime jsRuntime)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
@@ -51,17 +51,17 @@ public class AuthService
|
||||
Email = email,
|
||||
AvatarUrl = avatarUrl
|
||||
};
|
||||
|
||||
|
||||
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", "api_token", _apiToken);
|
||||
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", "user_info", JsonSerializer.Serialize(_userInfo));
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _apiToken);
|
||||
|
||||
|
||||
_isAuthenticated = true;
|
||||
Console.WriteLine($"✅ Backend validation successful. UserId={result.Id}");
|
||||
AuthenticationStateChanged?.Invoke(true);
|
||||
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@ public class AuthService
|
||||
// Restore header
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _apiToken);
|
||||
|
||||
|
||||
Console.WriteLine($"✅ Session restored: {_userInfo?.Email}");
|
||||
}
|
||||
else
|
||||
@@ -137,11 +137,11 @@ public class AuthService
|
||||
Console.WriteLine("=== AuthService.ClearAuthenticationAsync ===");
|
||||
await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", "api_token");
|
||||
await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", "user_info");
|
||||
|
||||
|
||||
_apiToken = null;
|
||||
_isAuthenticated = false;
|
||||
_userInfo = null;
|
||||
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
|
||||
Console.WriteLine("✅ Authentication cleared");
|
||||
|
||||
18
DiunaBI.UI.Shared/Services/DataInboxFilterStateService.cs
Normal file
18
DiunaBI.UI.Shared/Services/DataInboxFilterStateService.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using DiunaBI.Application.DTOModels;
|
||||
|
||||
namespace DiunaBI.UI.Shared.Services;
|
||||
|
||||
public class DataInboxFilterStateService
|
||||
{
|
||||
public DataInboxFilterRequest FilterRequest { get; set; } = new();
|
||||
|
||||
public void UpdateFilter(DataInboxFilterRequest request)
|
||||
{
|
||||
FilterRequest = request;
|
||||
}
|
||||
|
||||
public void ClearFilter()
|
||||
{
|
||||
FilterRequest = new DataInboxFilterRequest();
|
||||
}
|
||||
}
|
||||
18
DiunaBI.UI.Shared/Services/LayerFilterStateService.cs
Normal file
18
DiunaBI.UI.Shared/Services/LayerFilterStateService.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using DiunaBI.Application.DTOModels;
|
||||
|
||||
namespace DiunaBI.UI.Shared.Services;
|
||||
|
||||
public class LayerFilterStateService
|
||||
{
|
||||
public LayerFilterRequest FilterRequest { get; set; } = new();
|
||||
|
||||
public void UpdateFilter(LayerFilterRequest request)
|
||||
{
|
||||
FilterRequest = request;
|
||||
}
|
||||
|
||||
public void ClearFilter()
|
||||
{
|
||||
FilterRequest = new LayerFilterRequest();
|
||||
}
|
||||
}
|
||||
@@ -56,4 +56,66 @@ public class LayerService
|
||||
// For now we don't need it for read-only view
|
||||
return await Task.FromResult(false);
|
||||
}
|
||||
|
||||
public async Task<RecordDto?> CreateRecordAsync(Guid layerId, RecordDto record)
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync($"Layers/{layerId}/records", record);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
Console.Error.WriteLine($"CreateRecordAsync failed: {response.StatusCode} - {error}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<RecordDto>();
|
||||
}
|
||||
|
||||
public async Task<RecordDto?> UpdateRecordAsync(Guid layerId, Guid recordId, RecordDto record)
|
||||
{
|
||||
var response = await _httpClient.PutAsJsonAsync($"Layers/{layerId}/records/{recordId}", record);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
Console.Error.WriteLine($"UpdateRecordAsync failed: {response.StatusCode} - {error}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<RecordDto>();
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteRecordAsync(Guid layerId, Guid recordId)
|
||||
{
|
||||
var response = await _httpClient.DeleteAsync($"Layers/{layerId}/records/{recordId}");
|
||||
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>();
|
||||
}
|
||||
}
|
||||
6
DiunaBI.UI.Shared/Services/TokenProvider.cs
Normal file
6
DiunaBI.UI.Shared/Services/TokenProvider.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace DiunaBI.UI.Shared.Services;
|
||||
|
||||
public class TokenProvider
|
||||
{
|
||||
public string? Token { get; set; }
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<title>DiunaBI App</title>
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 4.1 KiB |
Reference in New Issue
Block a user