Add services feature with mock data and components

Introduces comprehensive services management module with:
- Dynamic service and category tables
- Localization support for services section
- Mock data for services and categories
- Responsive UI components for services listing
- Menu navigation and styling updates

Enhances application's service management capabilities
This commit is contained in:
Janus C. H. Knudsen 2026-01-15 23:29:26 +01:00
parent 408e590922
commit 4cf30e1f27
20 changed files with 951 additions and 0 deletions

View file

@ -0,0 +1,68 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Services.Components;
public class CategoryTableViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
private readonly IWebHostEnvironment _env;
public CategoryTableViewComponent(ILocalizationService localization, IWebHostEnvironment env)
{
_localization = localization;
_env = env;
}
public IViewComponentResult Invoke(string key)
{
var data = LoadServiceData();
var model = new CategoryTableViewModel
{
Key = key,
CreateButtonText = _localization.Get("services.createCategory"),
ColumnCategory = _localization.Get("services.table.category"),
ColumnServiceCount = _localization.Get("services.table.serviceCount"),
Categories = data.Categories
.OrderBy(c => c.SortOrder)
.Select(c => new CategoryItemViewModel
{
Id = c.Id,
Name = c.Name,
SortOrder = c.SortOrder,
ServiceCount = data.Services.Count(s => s.CategoryId == c.Id)
})
.ToList()
};
return View(model);
}
private ServiceMockData LoadServiceData()
{
var jsonPath = Path.Combine(_env.ContentRootPath, "Features", "Services", "Data", "servicesMock.json");
var json = System.IO.File.ReadAllText(jsonPath);
return JsonSerializer.Deserialize<ServiceMockData>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
}) ?? new ServiceMockData();
}
}
public class CategoryTableViewModel
{
public required string Key { get; init; }
public required string CreateButtonText { get; init; }
public required string ColumnCategory { get; init; }
public required string ColumnServiceCount { get; init; }
public required IReadOnlyList<CategoryItemViewModel> Categories { get; init; }
}
public class CategoryItemViewModel
{
public required string Id { get; init; }
public required string Name { get; init; }
public int SortOrder { get; init; }
public int ServiceCount { get; init; }
}

View file

@ -0,0 +1,31 @@
@model PlanTempus.Application.Features.Services.Components.CategoryTableViewModel
<swp-services-header>
<div></div>
<swp-btn class="primary">
<i class="ph ph-plus"></i>
@Model.CreateButtonText
</swp-btn>
</swp-services-header>
<swp-card class="categories-list">
<swp-data-table>
<swp-data-table-header>
<swp-data-table-cell>@Model.ColumnCategory</swp-data-table-cell>
<swp-data-table-cell>@Model.ColumnServiceCount</swp-data-table-cell>
<swp-data-table-cell></swp-data-table-cell>
</swp-data-table-header>
@foreach (var category in Model.Categories)
{
<swp-data-table-row data-category-detail="@category.Id">
<swp-data-table-cell>@category.Name</swp-data-table-cell>
<swp-data-table-cell>@category.ServiceCount</swp-data-table-cell>
<swp-data-table-cell>
<swp-row-toggle>
<i class="ph ph-caret-right"></i>
</swp-row-toggle>
</swp-data-table-cell>
</swp-data-table-row>
}
</swp-data-table>
</swp-card>

View file

@ -0,0 +1,19 @@
@model PlanTempus.Application.Features.Services.Components.ServiceCategoryViewModel
<swp-category-row data-category="@Model.Id" data-expanded="true">
<swp-data-table-cell>
<swp-category-toggle>
<i class="ph ph-caret-down"></i>
</swp-category-toggle>
<span class="category-name">@Model.Name</span>
<span class="category-count">(@Model.Services.Count)</span>
</swp-data-table-cell>
<swp-data-table-cell></swp-data-table-cell>
<swp-data-table-cell></swp-data-table-cell>
<swp-data-table-cell></swp-data-table-cell>
</swp-category-row>
@foreach (var service in Model.Services)
{
@await Component.InvokeAsync("ServiceRow", service)
}

View file

@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc;
namespace PlanTempus.Application.Features.Services.Components;
public class ServiceCategoryGroupViewComponent : ViewComponent
{
public IViewComponentResult Invoke(ServiceCategoryViewModel category)
{
return View(category);
}
}

View file

@ -0,0 +1,12 @@
@model PlanTempus.Application.Features.Services.Components.ServiceItemViewModel
<swp-data-table-row data-service-detail="@Model.Id">
<swp-data-table-cell>@Model.Name</swp-data-table-cell>
<swp-data-table-cell>@Model.Duration min</swp-data-table-cell>
<swp-data-table-cell>@Model.Price.ToString("N0") kr</swp-data-table-cell>
<swp-data-table-cell>
<swp-row-toggle>
<i class="ph ph-caret-right"></i>
</swp-row-toggle>
</swp-data-table-cell>
</swp-data-table-row>

View file

@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc;
namespace PlanTempus.Application.Features.Services.Components;
public class ServiceRowViewComponent : ViewComponent
{
public IViewComponentResult Invoke(ServiceItemViewModel service)
{
return View(service);
}
}

View file

@ -0,0 +1,6 @@
@model PlanTempus.Application.Features.Services.Components.ServiceStatCardViewModel
<swp-stat-card data-key="@Model.Key" class="@Model.Variant">
<swp-stat-value>@Model.Value</swp-stat-value>
<swp-stat-label>@Model.Label</swp-stat-label>
</swp-stat-card>

View file

@ -0,0 +1,80 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Services.Components;
public class ServiceStatCardViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public ServiceStatCardViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var model = ServiceStatCardCatalog.Get(key, _localization);
return View(model);
}
}
public class ServiceStatCardViewModel
{
public required string Key { get; init; }
public required string Value { get; init; }
public required string Label { get; init; }
public string? Variant { get; init; }
}
internal class ServiceStatCardData
{
public required string Key { get; init; }
public required string Value { get; init; }
public required string LabelKey { get; init; }
public string? Variant { get; init; }
}
public static class ServiceStatCardCatalog
{
private static readonly Dictionary<string, ServiceStatCardData> Cards = new()
{
["total-services"] = new ServiceStatCardData
{
Key = "total-services",
Value = "78",
LabelKey = "services.stats.totalServices",
Variant = "teal"
},
["active-categories"] = new ServiceStatCardData
{
Key = "active-categories",
Value = "14",
LabelKey = "services.stats.activeCategories",
Variant = "purple"
},
["average-price"] = new ServiceStatCardData
{
Key = "average-price",
Value = "856 kr",
LabelKey = "services.stats.averagePrice",
Variant = "amber"
}
};
public static ServiceStatCardViewModel Get(string key, ILocalizationService localization)
{
if (!Cards.TryGetValue(key, out var card))
throw new KeyNotFoundException($"ServiceStatCard with key '{key}' not found");
return new ServiceStatCardViewModel
{
Key = card.Key,
Value = card.Value,
Label = localization.Get(card.LabelKey),
Variant = card.Variant
};
}
public static IEnumerable<string> AllKeys => Cards.Keys;
}

View file

@ -0,0 +1,27 @@
@model PlanTempus.Application.Features.Services.Components.ServiceTableViewModel
<swp-services-header>
<swp-search-input>
<i class="ph ph-magnifying-glass"></i>
<input type="text" placeholder="@Model.SearchPlaceholder" />
</swp-search-input>
<swp-btn class="primary">
<i class="ph ph-plus"></i>
@Model.CreateButtonText
</swp-btn>
</swp-services-header>
<swp-card class="services-list">
<swp-data-table>
<swp-data-table-header>
<swp-data-table-cell>@Model.ColumnService</swp-data-table-cell>
<swp-data-table-cell>@Model.ColumnDuration</swp-data-table-cell>
<swp-data-table-cell>@Model.ColumnPrice</swp-data-table-cell>
<swp-data-table-cell></swp-data-table-cell>
</swp-data-table-header>
@foreach (var category in Model.Categories)
{
@await Component.InvokeAsync("ServiceCategoryGroup", category)
}
</swp-data-table>
</swp-card>

View file

@ -0,0 +1,110 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Services.Components;
public class ServiceTableViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
private readonly IWebHostEnvironment _env;
public ServiceTableViewComponent(ILocalizationService localization, IWebHostEnvironment env)
{
_localization = localization;
_env = env;
}
public IViewComponentResult Invoke(string key)
{
var data = LoadServiceData();
var model = new ServiceTableViewModel
{
Key = key,
SearchPlaceholder = _localization.Get("services.searchPlaceholder"),
CreateButtonText = _localization.Get("services.createService"),
ColumnService = _localization.Get("services.table.service"),
ColumnDuration = _localization.Get("services.table.duration"),
ColumnPrice = _localization.Get("services.table.price"),
Categories = data.Categories
.OrderBy(c => c.SortOrder)
.Select(c => new ServiceCategoryViewModel
{
Id = c.Id,
Name = c.Name,
Services = data.Services
.Where(s => s.CategoryId == c.Id)
.Select(s => new ServiceItemViewModel
{
Id = s.Id,
Name = s.Name,
Duration = s.Duration,
Price = s.Price
})
.ToList()
})
.Where(c => c.Services.Any())
.ToList()
};
return View(model);
}
private ServiceMockData LoadServiceData()
{
var jsonPath = Path.Combine(_env.ContentRootPath, "Features", "Services", "Data", "servicesMock.json");
var json = System.IO.File.ReadAllText(jsonPath);
return JsonSerializer.Deserialize<ServiceMockData>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
}) ?? new ServiceMockData();
}
}
public class ServiceTableViewModel
{
public required string Key { get; init; }
public required string SearchPlaceholder { get; init; }
public required string CreateButtonText { get; init; }
public required string ColumnService { get; init; }
public required string ColumnDuration { get; init; }
public required string ColumnPrice { get; init; }
public required IReadOnlyList<ServiceCategoryViewModel> Categories { get; init; }
}
public class ServiceCategoryViewModel
{
public required string Id { get; init; }
public required string Name { get; init; }
public required IReadOnlyList<ServiceItemViewModel> Services { get; init; }
}
public class ServiceItemViewModel
{
public required string Id { get; init; }
public required string Name { get; init; }
public int Duration { get; init; }
public decimal Price { get; init; }
}
internal class ServiceMockData
{
public List<ServiceCategoryData> Categories { get; set; } = new();
public List<ServiceData> Services { get; set; } = new();
}
internal class ServiceCategoryData
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public int SortOrder { get; set; }
}
internal class ServiceData
{
public string Id { get; set; } = "";
public string CategoryId { get; set; } = "";
public string Name { get; set; } = "";
public int Duration { get; set; }
public decimal Price { get; set; }
}