refactor(ui): redesign Areas page with TreeView and dedicated Add/Edit/Delete pages
Areas page now shows a single TreeView with sites as roots and areas as children. Context menus: sites get "Add Area", areas get "Add Child Area", "Edit Area", "Delete Area" — each navigating to a dedicated page. The Delete Area page shows a TreeView of the area and all recursive children with assigned instances. Deletion is blocked if any instances are assigned to the area or its descendants.
This commit is contained in:
127
src/ScadaLink.CentralUI/Components/Pages/Admin/AreaAdd.razor
Normal file
127
src/ScadaLink.CentralUI/Components/Pages/Admin/AreaAdd.razor
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
@page "/admin/areas/add"
|
||||||
|
@using ScadaLink.Security
|
||||||
|
@using ScadaLink.Commons.Entities.Instances
|
||||||
|
@using ScadaLink.Commons.Entities.Sites
|
||||||
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||||
|
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||||
|
@inject ISiteRepository SiteRepository
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
|
<div class="container-fluid mt-3">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm me-3">← Back</a>
|
||||||
|
<h4 class="mb-0">Add Area</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ToastNotification @ref="_toast" />
|
||||||
|
|
||||||
|
@if (_loading)
|
||||||
|
{
|
||||||
|
<LoadingSpinner IsLoading="true" />
|
||||||
|
}
|
||||||
|
else if (_errorMessage != null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">@_errorMessage</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="card" style="max-width: 500px;">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small">Site</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" value="@_siteName" readonly />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small">Parent Area</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="_parentAreaId">
|
||||||
|
<option value="0">(Root level)</option>
|
||||||
|
@foreach (var area in _areas)
|
||||||
|
{
|
||||||
|
<option value="@area.Id">@GetAreaPath(area)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small">Name</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" @bind="_name" placeholder="Area name" />
|
||||||
|
</div>
|
||||||
|
@if (_formError != null)
|
||||||
|
{
|
||||||
|
<div class="text-danger small mb-2">@_formError</div>
|
||||||
|
}
|
||||||
|
<button class="btn btn-success btn-sm" @onclick="Save" disabled="@_saving">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[SupplyParameterFromQuery] public int SiteId { get; set; }
|
||||||
|
[SupplyParameterFromQuery] public int ParentAreaId { get; set; }
|
||||||
|
|
||||||
|
private string _siteName = string.Empty;
|
||||||
|
private List<Area> _areas = new();
|
||||||
|
private int _parentAreaId;
|
||||||
|
private string _name = string.Empty;
|
||||||
|
private string? _formError;
|
||||||
|
private string? _errorMessage;
|
||||||
|
private bool _loading = true;
|
||||||
|
private bool _saving;
|
||||||
|
|
||||||
|
private ToastNotification _toast = default!;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var site = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.Id == SiteId);
|
||||||
|
_siteName = site?.Name ?? $"Site #{SiteId}";
|
||||||
|
_areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(SiteId)).ToList();
|
||||||
|
_parentAreaId = ParentAreaId;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_errorMessage = $"Failed to load: {ex.Message}";
|
||||||
|
}
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetAreaPath(Area area)
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
var current = area;
|
||||||
|
while (current != null)
|
||||||
|
{
|
||||||
|
parts.Insert(0, current.Name);
|
||||||
|
current = current.ParentAreaId.HasValue
|
||||||
|
? _areas.FirstOrDefault(a => a.Id == current.ParentAreaId.Value)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return string.Join(" / ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Save()
|
||||||
|
{
|
||||||
|
_formError = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(_name)) { _formError = "Name is required."; return; }
|
||||||
|
|
||||||
|
_saving = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var area = new Area(_name.Trim())
|
||||||
|
{
|
||||||
|
SiteId = SiteId,
|
||||||
|
ParentAreaId = _parentAreaId == 0 ? null : _parentAreaId
|
||||||
|
};
|
||||||
|
await TemplateEngineRepository.AddAreaAsync(area);
|
||||||
|
await TemplateEngineRepository.SaveChangesAsync();
|
||||||
|
NavigationManager.NavigateTo("/admin/areas");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_formError = $"Save failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
_saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
199
src/ScadaLink.CentralUI/Components/Pages/Admin/AreaDelete.razor
Normal file
199
src/ScadaLink.CentralUI/Components/Pages/Admin/AreaDelete.razor
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
@page "/admin/areas/{Id:int}/delete"
|
||||||
|
@using ScadaLink.Security
|
||||||
|
@using ScadaLink.Commons.Entities.Instances
|
||||||
|
@using ScadaLink.Commons.Entities.Sites
|
||||||
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||||
|
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||||
|
@inject ISiteRepository SiteRepository
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
|
<div class="container-fluid mt-3">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm me-3">← Back</a>
|
||||||
|
<h4 class="mb-0">Delete Area</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ToastNotification @ref="_toast" />
|
||||||
|
<ConfirmDialog @ref="_confirmDialog" />
|
||||||
|
|
||||||
|
@if (_loading)
|
||||||
|
{
|
||||||
|
<LoadingSpinner IsLoading="true" />
|
||||||
|
}
|
||||||
|
else if (_errorMessage != null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">@_errorMessage</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="card mb-3" style="max-width: 700px;">
|
||||||
|
<div class="card-body">
|
||||||
|
<p>
|
||||||
|
You are about to delete area <strong>@_area!.Name</strong>.
|
||||||
|
@if (_hasBlockingInstances)
|
||||||
|
{
|
||||||
|
<span class="text-danger">This area (or its children) has instances assigned. Remove or reassign instances before deleting.</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h6 class="mt-3">Area hierarchy with assigned instances:</h6>
|
||||||
|
|
||||||
|
<TreeView TItem="DeleteTreeNode" Items="_treeRoots"
|
||||||
|
ChildrenSelector="n => n.Children"
|
||||||
|
HasChildrenSelector="n => n.Children.Count > 0"
|
||||||
|
KeySelector="n => n.Key"
|
||||||
|
InitiallyExpanded="_ => true">
|
||||||
|
<NodeContent Context="node">
|
||||||
|
@if (node.Kind == DeleteNodeKind.Area)
|
||||||
|
{
|
||||||
|
<span class="@(node.HasInstances ? "text-danger fw-semibold" : "")">@node.Label</span>
|
||||||
|
@if (node.HasInstances)
|
||||||
|
{
|
||||||
|
<span class="badge bg-danger ms-1">@node.InstanceCount instance(s)</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted small">@node.Label</span>
|
||||||
|
}
|
||||||
|
</NodeContent>
|
||||||
|
<EmptyContent>
|
||||||
|
<span class="text-muted fst-italic">No child areas.</span>
|
||||||
|
</EmptyContent>
|
||||||
|
</TreeView>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
@if (_hasBlockingInstances)
|
||||||
|
{
|
||||||
|
<button class="btn btn-danger btn-sm" disabled>Delete (blocked)</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-danger btn-sm" @onclick="Delete" disabled="@_deleting">Delete Area</button>
|
||||||
|
}
|
||||||
|
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm ms-2">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public int Id { get; set; }
|
||||||
|
|
||||||
|
record DeleteTreeNode(string Key, string Label, DeleteNodeKind Kind, List<DeleteTreeNode> Children,
|
||||||
|
bool HasInstances = false, int InstanceCount = 0);
|
||||||
|
enum DeleteNodeKind { Area, Instance }
|
||||||
|
|
||||||
|
private Area? _area;
|
||||||
|
private List<DeleteTreeNode> _treeRoots = new();
|
||||||
|
private bool _hasBlockingInstances;
|
||||||
|
private bool _loading = true;
|
||||||
|
private bool _deleting;
|
||||||
|
private string? _errorMessage;
|
||||||
|
|
||||||
|
private ToastNotification _toast = default!;
|
||||||
|
private ConfirmDialog _confirmDialog = default!;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_area = await TemplateEngineRepository.GetAreaByIdAsync(Id);
|
||||||
|
if (_area == null)
|
||||||
|
{
|
||||||
|
_errorMessage = $"Area #{Id} not found.";
|
||||||
|
_loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all areas for this site to build hierarchy
|
||||||
|
var allAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_area.SiteId)).ToList();
|
||||||
|
var allInstances = (await TemplateEngineRepository.GetAllInstancesAsync())
|
||||||
|
.Where(i => i.SiteId == _area.SiteId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var rootNode = BuildDeleteTree(_area, allAreas, allInstances);
|
||||||
|
_treeRoots = new List<DeleteTreeNode> { rootNode };
|
||||||
|
_hasBlockingInstances = HasAnyInstances(rootNode);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_errorMessage = $"Failed to load: {ex.Message}";
|
||||||
|
}
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DeleteTreeNode BuildDeleteTree(Area area, List<Area> allAreas, List<Instance> allInstances)
|
||||||
|
{
|
||||||
|
var children = new List<DeleteTreeNode>();
|
||||||
|
|
||||||
|
// Add child areas recursively
|
||||||
|
var childAreas = allAreas.Where(a => a.ParentAreaId == area.Id).OrderBy(a => a.Name);
|
||||||
|
foreach (var child in childAreas)
|
||||||
|
{
|
||||||
|
children.Add(BuildDeleteTree(child, allAreas, allInstances));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add instances assigned to this area
|
||||||
|
var areaInstances = allInstances.Where(i => i.AreaId == area.Id).OrderBy(i => i.UniqueName);
|
||||||
|
foreach (var inst in areaInstances)
|
||||||
|
{
|
||||||
|
children.Add(new DeleteTreeNode(
|
||||||
|
Key: $"inst-{inst.Id}",
|
||||||
|
Label: inst.UniqueName,
|
||||||
|
Kind: DeleteNodeKind.Instance,
|
||||||
|
Children: new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
var instanceCount = areaInstances.Count();
|
||||||
|
return new DeleteTreeNode(
|
||||||
|
Key: $"area-{area.Id}",
|
||||||
|
Label: area.Name,
|
||||||
|
Kind: DeleteNodeKind.Area,
|
||||||
|
Children: children,
|
||||||
|
HasInstances: instanceCount > 0,
|
||||||
|
InstanceCount: instanceCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HasAnyInstances(DeleteTreeNode node)
|
||||||
|
{
|
||||||
|
if (node.Kind == DeleteNodeKind.Instance) return true;
|
||||||
|
return node.Children.Any(HasAnyInstances);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Delete()
|
||||||
|
{
|
||||||
|
var confirmed = await _confirmDialog.ShowAsync(
|
||||||
|
$"Permanently delete area '{_area!.Name}' and all its child areas?",
|
||||||
|
"Confirm Delete");
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
_deleting = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Delete child areas bottom-up (deepest first)
|
||||||
|
await DeleteAreaRecursive(_area!);
|
||||||
|
await TemplateEngineRepository.SaveChangesAsync();
|
||||||
|
NavigationManager.NavigateTo("/admin/areas");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_toast.ShowError($"Delete failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
_deleting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAreaRecursive(Area area)
|
||||||
|
{
|
||||||
|
// Load fresh children in case the collection wasn't populated
|
||||||
|
var allAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(area.SiteId)).ToList();
|
||||||
|
var children = allAreas.Where(a => a.ParentAreaId == area.Id).ToList();
|
||||||
|
foreach (var child in children)
|
||||||
|
{
|
||||||
|
await DeleteAreaRecursive(child);
|
||||||
|
}
|
||||||
|
await TemplateEngineRepository.DeleteAreaAsync(area.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
@page "/admin/areas/{Id:int}/edit"
|
||||||
|
@using ScadaLink.Security
|
||||||
|
@using ScadaLink.Commons.Entities.Instances
|
||||||
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||||
|
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
|
<div class="container-fluid mt-3">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm me-3">← Back</a>
|
||||||
|
<h4 class="mb-0">Edit Area</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ToastNotification @ref="_toast" />
|
||||||
|
|
||||||
|
@if (_loading)
|
||||||
|
{
|
||||||
|
<LoadingSpinner IsLoading="true" />
|
||||||
|
}
|
||||||
|
else if (_errorMessage != null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">@_errorMessage</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="card" style="max-width: 500px;">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small">Name</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" @bind="_name" />
|
||||||
|
</div>
|
||||||
|
@if (_formError != null)
|
||||||
|
{
|
||||||
|
<div class="text-danger small mb-2">@_formError</div>
|
||||||
|
}
|
||||||
|
<button class="btn btn-success btn-sm" @onclick="Save" disabled="@_saving">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public int Id { get; set; }
|
||||||
|
|
||||||
|
private Area? _area;
|
||||||
|
private string _name = string.Empty;
|
||||||
|
private string? _formError;
|
||||||
|
private string? _errorMessage;
|
||||||
|
private bool _loading = true;
|
||||||
|
private bool _saving;
|
||||||
|
|
||||||
|
private ToastNotification _toast = default!;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_area = await TemplateEngineRepository.GetAreaByIdAsync(Id);
|
||||||
|
if (_area == null)
|
||||||
|
{
|
||||||
|
_errorMessage = $"Area #{Id} not found.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_name = _area.Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_errorMessage = $"Failed to load area: {ex.Message}";
|
||||||
|
}
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Save()
|
||||||
|
{
|
||||||
|
_formError = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(_name)) { _formError = "Name is required."; return; }
|
||||||
|
|
||||||
|
_saving = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_area!.Name = _name.Trim();
|
||||||
|
await TemplateEngineRepository.UpdateAreaAsync(_area);
|
||||||
|
await TemplateEngineRepository.SaveChangesAsync();
|
||||||
|
NavigationManager.NavigateTo("/admin/areas");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_formError = $"Save failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
_saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,14 +6,12 @@
|
|||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||||
@inject ISiteRepository SiteRepository
|
@inject ISiteRepository SiteRepository
|
||||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<h4 class="mb-3">Area Management</h4>
|
||||||
<h4 class="mb-0">Area Management</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ToastNotification @ref="_toast" />
|
<ToastNotification @ref="_toast" />
|
||||||
<ConfirmDialog @ref="_confirmDialog" />
|
|
||||||
|
|
||||||
@if (_loading)
|
@if (_loading)
|
||||||
{
|
{
|
||||||
@@ -25,246 +23,111 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="row">
|
<TreeView TItem="AreaTreeNode" Items="_treeRoots"
|
||||||
<div class="col-md-3">
|
ChildrenSelector="n => n.Children"
|
||||||
<div class="card">
|
HasChildrenSelector="n => n.Children.Count > 0"
|
||||||
<div class="card-header">
|
KeySelector="n => n.Key"
|
||||||
<h6 class="mb-0">Sites</h6>
|
StorageKey="areas-tree">
|
||||||
</div>
|
<NodeContent Context="node">
|
||||||
<div class="list-group list-group-flush">
|
@if (node.Kind == AreaNodeKind.Site)
|
||||||
@if (_sites.Count == 0)
|
|
||||||
{
|
|
||||||
<div class="list-group-item text-muted small">No sites configured.</div>
|
|
||||||
}
|
|
||||||
@foreach (var site in _sites)
|
|
||||||
{
|
|
||||||
<button type="button"
|
|
||||||
class="list-group-item list-group-item-action @(site.Id == _selectedSiteId ? "active" : "")"
|
|
||||||
@onclick="() => SelectSite(site.Id)">
|
|
||||||
@site.Name
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-9">
|
|
||||||
@if (_selectedSiteId == 0)
|
|
||||||
{
|
{
|
||||||
<div class="text-muted">Select a site to manage its areas.</div>
|
<span class="fw-semibold">@node.Label</span>
|
||||||
|
<span class="badge bg-secondary ms-1">@node.AreaCount</span>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<span>@node.Label</span>
|
||||||
<h5 class="mb-0">Areas for @(_sites.FirstOrDefault(s => s.Id == _selectedSiteId)?.Name)</h5>
|
|
||||||
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add Area</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (_showForm)
|
|
||||||
{
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-body">
|
|
||||||
<h6 class="card-title">@(_editingArea == null ? "Add New Area" : "Edit Area")</h6>
|
|
||||||
<div class="row g-2 align-items-end">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label small">Name</label>
|
|
||||||
<input type="text" class="form-control form-control-sm" @bind="_formName" />
|
|
||||||
</div>
|
|
||||||
@if (_editingArea == null)
|
|
||||||
{
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label small">Parent Area</label>
|
|
||||||
<select class="form-select form-select-sm" @bind="_formParentAreaId">
|
|
||||||
<option value="0">(Root level)</option>
|
|
||||||
@foreach (var area in _areas)
|
|
||||||
{
|
|
||||||
<option value="@area.Id">@GetAreaPath(area)</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="col-md-4">
|
|
||||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveArea">Save</button>
|
|
||||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelForm">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@if (_formError != null)
|
|
||||||
{
|
|
||||||
<div class="text-danger small mt-1">@_formError</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<TreeView TItem="Area" Items="@_rootAreas"
|
|
||||||
ChildrenSelector='a => a.Children.OrderBy(c => c.Name).ToList()'
|
|
||||||
HasChildrenSelector="a => a.Children.Any()"
|
|
||||||
KeySelector="a => (object)a.Id"
|
|
||||||
StorageKey="areas-tree">
|
|
||||||
<NodeContent Context="area">
|
|
||||||
<span>@area.Name</span>
|
|
||||||
</NodeContent>
|
|
||||||
<ContextMenu Context="area">
|
|
||||||
<button class="dropdown-item" @onclick="() => EditArea(area)">
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<div class="dropdown-divider"></div>
|
|
||||||
<button class="dropdown-item text-danger" @onclick="() => DeleteArea(area)">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</ContextMenu>
|
|
||||||
<EmptyContent>
|
|
||||||
<span class="text-muted fst-italic">No areas for this site. Add one above.</span>
|
|
||||||
</EmptyContent>
|
|
||||||
</TreeView>
|
|
||||||
}
|
}
|
||||||
</div>
|
</NodeContent>
|
||||||
</div>
|
<ContextMenu Context="node">
|
||||||
|
@if (node.Kind == AreaNodeKind.Site)
|
||||||
|
{
|
||||||
|
<button class="dropdown-item"
|
||||||
|
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/add?siteId={node.SiteId}")'>
|
||||||
|
Add Area
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="dropdown-item"
|
||||||
|
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/add?siteId={node.SiteId}&parentAreaId={node.Area!.Id}")'>
|
||||||
|
Add Child Area
|
||||||
|
</button>
|
||||||
|
<button class="dropdown-item"
|
||||||
|
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/{node.Area!.Id}/edit")'>
|
||||||
|
Edit Area
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<button class="dropdown-item text-danger"
|
||||||
|
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/{node.Area!.Id}/delete")'>
|
||||||
|
Delete Area
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</ContextMenu>
|
||||||
|
<EmptyContent>
|
||||||
|
<span class="text-muted fst-italic">No sites configured.</span>
|
||||||
|
</EmptyContent>
|
||||||
|
</TreeView>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
record AreaTreeNode(string Key, string Label, AreaNodeKind Kind, List<AreaTreeNode> Children,
|
||||||
|
int SiteId, Area? Area = null, int AreaCount = 0);
|
||||||
|
enum AreaNodeKind { Site, Area }
|
||||||
|
|
||||||
private List<Site> _sites = new();
|
private List<Site> _sites = new();
|
||||||
private List<Area> _areas = new();
|
private List<AreaTreeNode> _treeRoots = new();
|
||||||
private int _selectedSiteId;
|
|
||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
private string? _errorMessage;
|
private string? _errorMessage;
|
||||||
|
|
||||||
private bool _showForm;
|
|
||||||
private Area? _editingArea;
|
|
||||||
private string _formName = string.Empty;
|
|
||||||
private int _formParentAreaId;
|
|
||||||
private string? _formError;
|
|
||||||
|
|
||||||
private ToastNotification _toast = default!;
|
private ToastNotification _toast = default!;
|
||||||
private ConfirmDialog _confirmDialog = default!;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadDataAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadDataAsync()
|
||||||
{
|
{
|
||||||
_loading = true;
|
_loading = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||||
|
_treeRoots = new();
|
||||||
|
foreach (var site in _sites)
|
||||||
|
{
|
||||||
|
var areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id)).ToList();
|
||||||
|
var rootAreas = areas.Where(a => a.ParentAreaId == null).OrderBy(a => a.Name);
|
||||||
|
var children = rootAreas.Select(a => BuildAreaNode(a, site.Id)).ToList();
|
||||||
|
_treeRoots.Add(new AreaTreeNode(
|
||||||
|
Key: $"site-{site.Id}",
|
||||||
|
Label: site.Name,
|
||||||
|
Kind: AreaNodeKind.Site,
|
||||||
|
Children: children,
|
||||||
|
SiteId: site.Id,
|
||||||
|
AreaCount: areas.Count));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_errorMessage = $"Failed to load sites: {ex.Message}";
|
_errorMessage = $"Failed to load data: {ex.Message}";
|
||||||
}
|
}
|
||||||
_loading = false;
|
_loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SelectSite(int siteId)
|
private AreaTreeNode BuildAreaNode(Area area, int siteId)
|
||||||
{
|
{
|
||||||
_selectedSiteId = siteId;
|
var children = area.Children
|
||||||
_showForm = false;
|
.OrderBy(c => c.Name)
|
||||||
await LoadAreasAsync();
|
.Select(c => BuildAreaNode(c, siteId))
|
||||||
}
|
.ToList();
|
||||||
|
return new AreaTreeNode(
|
||||||
private async Task LoadAreasAsync()
|
Key: $"area-{area.Id}",
|
||||||
{
|
Label: area.Name,
|
||||||
try
|
Kind: AreaNodeKind.Area,
|
||||||
{
|
Children: children,
|
||||||
_areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList();
|
SiteId: siteId,
|
||||||
}
|
Area: area);
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_errorMessage = $"Failed to load areas: {ex.Message}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<Area> _rootAreas => _areas.Where(a => a.ParentAreaId == null).OrderBy(a => a.Name).ToList();
|
|
||||||
|
|
||||||
private string GetAreaPath(Area area)
|
|
||||||
{
|
|
||||||
var parts = new List<string>();
|
|
||||||
var current = area;
|
|
||||||
while (current != null)
|
|
||||||
{
|
|
||||||
parts.Insert(0, current.Name);
|
|
||||||
current = current.ParentAreaId.HasValue
|
|
||||||
? _areas.FirstOrDefault(a => a.Id == current.ParentAreaId.Value)
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
return string.Join(" / ", parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ShowAddForm()
|
|
||||||
{
|
|
||||||
_editingArea = null;
|
|
||||||
_formName = string.Empty;
|
|
||||||
_formParentAreaId = 0;
|
|
||||||
_formError = null;
|
|
||||||
_showForm = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EditArea(Area area)
|
|
||||||
{
|
|
||||||
_editingArea = area;
|
|
||||||
_formName = area.Name;
|
|
||||||
_formError = null;
|
|
||||||
_showForm = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CancelForm()
|
|
||||||
{
|
|
||||||
_showForm = false;
|
|
||||||
_editingArea = null;
|
|
||||||
_formError = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveArea()
|
|
||||||
{
|
|
||||||
_formError = null;
|
|
||||||
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_editingArea != null)
|
|
||||||
{
|
|
||||||
_editingArea.Name = _formName.Trim();
|
|
||||||
await TemplateEngineRepository.UpdateAreaAsync(_editingArea);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var area = new Area(_formName.Trim())
|
|
||||||
{
|
|
||||||
SiteId = _selectedSiteId,
|
|
||||||
ParentAreaId = _formParentAreaId == 0 ? null : _formParentAreaId
|
|
||||||
};
|
|
||||||
await TemplateEngineRepository.AddAreaAsync(area);
|
|
||||||
}
|
|
||||||
await TemplateEngineRepository.SaveChangesAsync();
|
|
||||||
_showForm = false;
|
|
||||||
_editingArea = null;
|
|
||||||
_toast.ShowSuccess("Area saved.");
|
|
||||||
await LoadAreasAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_formError = $"Save failed: {ex.Message}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteArea(Area area)
|
|
||||||
{
|
|
||||||
var hasChildren = _areas.Any(a => a.ParentAreaId == area.Id);
|
|
||||||
var message = hasChildren
|
|
||||||
? $"Area '{area.Name}' has child areas. Delete child areas first."
|
|
||||||
: $"Delete area '{area.Name}'?";
|
|
||||||
|
|
||||||
var confirmed = await _confirmDialog.ShowAsync(message, "Delete Area");
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await TemplateEngineRepository.DeleteAreaAsync(area.Id);
|
|
||||||
await TemplateEngineRepository.SaveChangesAsync();
|
|
||||||
_toast.ShowSuccess($"Area '{area.Name}' deleted.");
|
|
||||||
await LoadAreasAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_toast.ShowError($"Delete failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user