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:
@@ -6,14 +6,12 @@
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Area Management</h4>
|
||||
</div>
|
||||
<h4 class="mb-3">Area Management</h4>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
<ConfirmDialog @ref="_confirmDialog" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
@@ -25,246 +23,111 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Sites</h6>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
@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)
|
||||
<TreeView TItem="AreaTreeNode" Items="_treeRoots"
|
||||
ChildrenSelector="n => n.Children"
|
||||
HasChildrenSelector="n => n.Children.Count > 0"
|
||||
KeySelector="n => n.Key"
|
||||
StorageKey="areas-tree">
|
||||
<NodeContent Context="node">
|
||||
@if (node.Kind == AreaNodeKind.Site)
|
||||
{
|
||||
<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
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<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>
|
||||
<span>@node.Label</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</NodeContent>
|
||||
<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>
|
||||
|
||||
@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<Area> _areas = new();
|
||||
private int _selectedSiteId;
|
||||
private List<AreaTreeNode> _treeRoots = new();
|
||||
private bool _loading = true;
|
||||
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 ConfirmDialog _confirmDialog = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_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)
|
||||
{
|
||||
_errorMessage = $"Failed to load sites: {ex.Message}";
|
||||
_errorMessage = $"Failed to load data: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task SelectSite(int siteId)
|
||||
private AreaTreeNode BuildAreaNode(Area area, int siteId)
|
||||
{
|
||||
_selectedSiteId = siteId;
|
||||
_showForm = false;
|
||||
await LoadAreasAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAreasAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList();
|
||||
}
|
||||
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}");
|
||||
}
|
||||
var children = area.Children
|
||||
.OrderBy(c => c.Name)
|
||||
.Select(c => BuildAreaNode(c, siteId))
|
||||
.ToList();
|
||||
return new AreaTreeNode(
|
||||
Key: $"area-{area.Id}",
|
||||
Label: area.Name,
|
||||
Kind: AreaNodeKind.Area,
|
||||
Children: children,
|
||||
SiteId: siteId,
|
||||
Area: area);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user