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:
parent
408e590922
commit
4cf30e1f27
20 changed files with 951 additions and 0 deletions
|
|
@ -7,6 +7,7 @@
|
|||
"suppliers": "Leverandører",
|
||||
"customers": "Kunder",
|
||||
"employees": "Medarbejdere",
|
||||
"services": "Services",
|
||||
"reports": "Statistik & Rapporter",
|
||||
"settings": "Indstillinger",
|
||||
"account": "Abonnement & Konto"
|
||||
|
|
@ -219,6 +220,29 @@
|
|||
"overdue": "Forfalden"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"title": "Services",
|
||||
"subtitle": "Administrer services og priser",
|
||||
"tabs": {
|
||||
"services": "Services",
|
||||
"categories": "Kategorier"
|
||||
},
|
||||
"stats": {
|
||||
"totalServices": "Services i alt",
|
||||
"activeCategories": "Aktive kategorier",
|
||||
"averagePrice": "Gns. pris"
|
||||
},
|
||||
"searchPlaceholder": "Søg efter service...",
|
||||
"createService": "Opret service",
|
||||
"createCategory": "Opret kategori",
|
||||
"table": {
|
||||
"service": "Service",
|
||||
"category": "Kategori",
|
||||
"duration": "Varighed",
|
||||
"price": "Pris",
|
||||
"serviceCount": "Antal services"
|
||||
}
|
||||
},
|
||||
"employees": {
|
||||
"title": "Medarbejdere",
|
||||
"subtitle": "Administrer brugere, roller og rettigheder",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"suppliers": "Suppliers",
|
||||
"customers": "Customers",
|
||||
"employees": "Employees",
|
||||
"services": "Services",
|
||||
"reports": "Statistics & Reports",
|
||||
"settings": "Settings",
|
||||
"account": "Subscription & Account"
|
||||
|
|
@ -219,6 +220,29 @@
|
|||
"overdue": "Overdue"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"title": "Services",
|
||||
"subtitle": "Manage services and pricing",
|
||||
"tabs": {
|
||||
"services": "Services",
|
||||
"categories": "Categories"
|
||||
},
|
||||
"stats": {
|
||||
"totalServices": "Total services",
|
||||
"activeCategories": "Active categories",
|
||||
"averagePrice": "Avg. price"
|
||||
},
|
||||
"searchPlaceholder": "Search for service...",
|
||||
"createService": "Create service",
|
||||
"createCategory": "Create category",
|
||||
"table": {
|
||||
"service": "Service",
|
||||
"category": "Category",
|
||||
"duration": "Duration",
|
||||
"price": "Price",
|
||||
"serviceCount": "Service count"
|
||||
}
|
||||
},
|
||||
"employees": {
|
||||
"title": "Employees",
|
||||
"subtitle": "Manage users, roles and permissions",
|
||||
|
|
|
|||
|
|
@ -130,6 +130,15 @@ public class MockMenuService : IMenuService
|
|||
Url = "/medarbejdere",
|
||||
MinimumRole = UserRole.Manager,
|
||||
SortOrder = 4
|
||||
},
|
||||
new MenuItem
|
||||
{
|
||||
Id = "services",
|
||||
Label = "Services",
|
||||
Icon = "ph-scissors",
|
||||
Url = "/services",
|
||||
MinimumRole = UserRole.Manager,
|
||||
SortOrder = 5
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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; }
|
||||
}
|
||||
108
PlanTempus.Application/Features/Services/Data/servicesMock.json
Normal file
108
PlanTempus.Application/Features/Services/Data/servicesMock.json
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
{
|
||||
"categories": [
|
||||
{ "id": "cat-1", "name": "Klip dame, herre og børn", "sortOrder": 1 },
|
||||
{ "id": "cat-2", "name": "Farvebehandlinger", "sortOrder": 2 },
|
||||
{ "id": "cat-3", "name": "Striber/Refleksbehandling", "sortOrder": 3 },
|
||||
{ "id": "cat-4", "name": "Hårvask med styling eller uden styling", "sortOrder": 4 },
|
||||
{ "id": "cat-5", "name": "Henna naturlig hårfarver", "sortOrder": 5 },
|
||||
{ "id": "cat-6", "name": "Kurbehandling", "sortOrder": 6 },
|
||||
{ "id": "cat-7", "name": "Bryn og vipper", "sortOrder": 7 },
|
||||
{ "id": "cat-8", "name": "Balayage", "sortOrder": 8 },
|
||||
{ "id": "cat-9", "name": "Skæg", "sortOrder": 9 },
|
||||
{ "id": "cat-10", "name": "Gloss", "sortOrder": 10 },
|
||||
{ "id": "cat-11", "name": "Håropsætning", "sortOrder": 11 },
|
||||
{ "id": "cat-12", "name": "Modeller", "sortOrder": 12 },
|
||||
{ "id": "cat-13", "name": "Tristan farve modeller", "sortOrder": 13 },
|
||||
{ "id": "cat-14", "name": "Tilvalg services", "sortOrder": 14 }
|
||||
],
|
||||
"services": [
|
||||
{ "id": "svc-1", "categoryId": "cat-1", "name": "Dameklip", "duration": 60, "price": 725 },
|
||||
{ "id": "svc-2", "categoryId": "cat-1", "name": "Dameklip uden snak", "duration": 60, "price": 725 },
|
||||
{ "id": "svc-3", "categoryId": "cat-1", "name": "Dameklip spidser mellemlangt og langt hår", "duration": 40, "price": 575 },
|
||||
{ "id": "svc-4", "categoryId": "cat-1", "name": "Dameklip Luksus ekstra glans og pleje", "duration": 75, "price": 925 },
|
||||
{ "id": "svc-5", "categoryId": "cat-1", "name": "Herreklip", "duration": 60, "price": 645 },
|
||||
{ "id": "svc-6", "categoryId": "cat-1", "name": "Herreklip uden snak", "duration": 60, "price": 645 },
|
||||
{ "id": "svc-7", "categoryId": "cat-1", "name": "Skin fade", "duration": 60, "price": 645 },
|
||||
{ "id": "svc-8", "categoryId": "cat-1", "name": "Klip med maskine (herre klip)", "duration": 30, "price": 475 },
|
||||
{ "id": "svc-9", "categoryId": "cat-1", "name": "Børneklip 0-4 år", "duration": 45, "price": 475 },
|
||||
{ "id": "svc-10", "categoryId": "cat-1", "name": "Børneklip 4-8 år", "duration": 45, "price": 525 },
|
||||
{ "id": "svc-11", "categoryId": "cat-1", "name": "Børneklip 9-12 år", "duration": 60, "price": 450 },
|
||||
{ "id": "svc-12", "categoryId": "cat-1", "name": "Touch up Dame", "duration": 10, "price": 0 },
|
||||
{ "id": "svc-13", "categoryId": "cat-1", "name": "Touch up Herre", "duration": 10, "price": 0 },
|
||||
{ "id": "svc-14", "categoryId": "cat-1", "name": "Pandehår helt nyt", "duration": 20, "price": 325 },
|
||||
{ "id": "svc-15", "categoryId": "cat-1", "name": "Konsultation uden behandling", "duration": 10, "price": 0 },
|
||||
|
||||
{ "id": "svc-20", "categoryId": "cat-2", "name": "Bundfarve almindelig udgroning maks 3 cm", "duration": 90, "price": 785 },
|
||||
{ "id": "svc-21", "categoryId": "cat-2", "name": "Helfarve kort hår", "duration": 105, "price": 950 },
|
||||
{ "id": "svc-22", "categoryId": "cat-2", "name": "Helfarve mellemlangt hår", "duration": 120, "price": 1450 },
|
||||
{ "id": "svc-23", "categoryId": "cat-2", "name": "Helfarve langt hår", "duration": 120, "price": 1550 },
|
||||
{ "id": "svc-24", "categoryId": "cat-2", "name": "Bundfarve/Lysning", "duration": 105, "price": 975 },
|
||||
{ "id": "svc-25", "categoryId": "cat-2", "name": "Afblegning kort hår + gloss", "duration": 150, "price": 1895 },
|
||||
|
||||
{ "id": "svc-30", "categoryId": "cat-3", "name": "Striber kort hår", "duration": 120, "price": 1465 },
|
||||
{ "id": "svc-31", "categoryId": "cat-3", "name": "Striber mellemlangt hår", "duration": 150, "price": 1665 },
|
||||
{ "id": "svc-32", "categoryId": "cat-3", "name": "Striber langt hår", "duration": 180, "price": 1865 },
|
||||
{ "id": "svc-33", "categoryId": "cat-3", "name": "Striber på toppen/overhår", "duration": 90, "price": 1065 },
|
||||
{ "id": "svc-34", "categoryId": "cat-3", "name": "Striber babylights tæt lysning mellemlangt hår", "duration": 180, "price": 2650 },
|
||||
{ "id": "svc-35", "categoryId": "cat-3", "name": "Striber babylights tæt lysning langt hår", "duration": 180, "price": 2850 },
|
||||
{ "id": "svc-36", "categoryId": "cat-3", "name": "Striber babylights tæt lysning på toppen", "duration": 120, "price": 1650 },
|
||||
{ "id": "svc-37", "categoryId": "cat-3", "name": "AirTouch skulderlangt hår", "duration": 210, "price": 3250 },
|
||||
{ "id": "svc-38", "categoryId": "cat-3", "name": "AirTouch langt hår", "duration": 240, "price": 3850 },
|
||||
|
||||
{ "id": "svc-40", "categoryId": "cat-4", "name": "Hårvask uden styling", "duration": 30, "price": 265 },
|
||||
{ "id": "svc-41", "categoryId": "cat-4", "name": "Hårvask med styling (kun føn)", "duration": 40, "price": 450 },
|
||||
{ "id": "svc-42", "categoryId": "cat-4", "name": "Vask + Styling med varme glatning/krøller (mellemlangt/langt)", "duration": 60, "price": 650 },
|
||||
|
||||
{ "id": "svc-50", "categoryId": "cat-5", "name": "Henna kort hår", "duration": 90, "price": 965 },
|
||||
{ "id": "svc-51", "categoryId": "cat-5", "name": "Henna mellemlangt/langt hår", "duration": 90, "price": 1265 },
|
||||
{ "id": "svc-52", "categoryId": "cat-5", "name": "Henna bundfarve", "duration": 90, "price": 750 },
|
||||
|
||||
{ "id": "svc-60", "categoryId": "cat-6", "name": "Olaplex Stand alone", "duration": 60, "price": 550 },
|
||||
{ "id": "svc-61", "categoryId": "cat-6", "name": "Kurbehandling fugt/protein", "duration": 40, "price": 365 },
|
||||
|
||||
{ "id": "svc-70", "categoryId": "cat-7", "name": "Farvning vipper & bryn", "duration": 30, "price": 345 },
|
||||
{ "id": "svc-71", "categoryId": "cat-7", "name": "Farvning vipper", "duration": 20, "price": 185 },
|
||||
{ "id": "svc-72", "categoryId": "cat-7", "name": "Farvning og retning af bryn", "duration": 20, "price": 185 },
|
||||
{ "id": "svc-73", "categoryId": "cat-7", "name": "Retning af bryn", "duration": 10, "price": 100 },
|
||||
|
||||
{ "id": "svc-80", "categoryId": "cat-8", "name": "Balayage maks til skulderen", "duration": 150, "price": 1850 },
|
||||
{ "id": "svc-81", "categoryId": "cat-8", "name": "Balayage maks skulder + gloss/toning", "duration": 180, "price": 2250 },
|
||||
{ "id": "svc-82", "categoryId": "cat-8", "name": "Balayage langt hår", "duration": 150, "price": 2150 },
|
||||
{ "id": "svc-83", "categoryId": "cat-8", "name": "Balayage langt hår + gloss/toning", "duration": 180, "price": 2550 },
|
||||
|
||||
{ "id": "svc-90", "categoryId": "cat-9", "name": "Skægtrim", "duration": 20, "price": 300 },
|
||||
|
||||
{ "id": "svc-100", "categoryId": "cat-10", "name": "Gloss ekstra langt/tykt hår", "duration": 75, "price": 900 },
|
||||
{ "id": "svc-101", "categoryId": "cat-10", "name": "Glossing kort hår", "duration": 60, "price": 685 },
|
||||
{ "id": "svc-102", "categoryId": "cat-10", "name": "Glossing mellemlangt/langt hår", "duration": 60, "price": 745 },
|
||||
{ "id": "svc-103", "categoryId": "cat-10", "name": "Glossing mænd", "duration": 40, "price": 350 },
|
||||
{ "id": "svc-104", "categoryId": "cat-10", "name": "Gloss ifb. anden farvebehandling", "duration": 20, "price": 450 },
|
||||
|
||||
{ "id": "svc-110", "categoryId": "cat-11", "name": "Håropsætning kort hår", "duration": 60, "price": 850 },
|
||||
{ "id": "svc-111", "categoryId": "cat-11", "name": "Håropsætning langt hår", "duration": 60, "price": 1450 },
|
||||
{ "id": "svc-112", "categoryId": "cat-11", "name": "Håropsætning Brud/brudepiger/Galla/Oscar", "duration": 90, "price": 1599 },
|
||||
{ "id": "svc-113", "categoryId": "cat-11", "name": "Make-up Special Brud/Galla mm", "duration": 90, "price": 3000 },
|
||||
|
||||
{ "id": "svc-120", "categoryId": "cat-12", "name": "Dameklip Model", "duration": 60, "price": 0 },
|
||||
{ "id": "svc-121", "categoryId": "cat-12", "name": "Herreklip Model", "duration": 60, "price": 0 },
|
||||
{ "id": "svc-122", "categoryId": "cat-12", "name": "Balayage Model", "duration": 240, "price": 0 },
|
||||
{ "id": "svc-123", "categoryId": "cat-12", "name": "Striber Model", "duration": 180, "price": 0 },
|
||||
{ "id": "svc-124", "categoryId": "cat-12", "name": "Bryn & Vippe Model", "duration": 40, "price": 0 },
|
||||
{ "id": "svc-125", "categoryId": "cat-12", "name": "Bundfarve Model", "duration": 120, "price": 0 },
|
||||
{ "id": "svc-126", "categoryId": "cat-12", "name": "Gloss Model", "duration": 30, "price": 0 },
|
||||
|
||||
{ "id": "svc-130", "categoryId": "cat-13", "name": "Bundfarve med HP/HPF", "duration": 90, "price": 325 },
|
||||
{ "id": "svc-131", "categoryId": "cat-13", "name": "Striber Model", "duration": 240, "price": 400 },
|
||||
|
||||
{ "id": "svc-140", "categoryId": "cat-14", "name": "Touch up kur", "duration": 15, "price": 175 },
|
||||
{ "id": "svc-141", "categoryId": "cat-14", "name": "Root shading", "duration": 30, "price": 425 },
|
||||
{ "id": "svc-142", "categoryId": "cat-14", "name": "Styling med varme (efter behandling)", "duration": 60, "price": 475 },
|
||||
{ "id": "svc-143", "categoryId": "cat-14", "name": "Styling kort hår (efter farve)", "duration": 20, "price": 175 },
|
||||
{ "id": "svc-144", "categoryId": "cat-14", "name": "Olaplex efter afblegning", "duration": 10, "price": 325 },
|
||||
{ "id": "svc-145", "categoryId": "cat-14", "name": "Let afrensning af gloss/klor/kemi", "duration": 20, "price": 220 },
|
||||
{ "id": "svc-146", "categoryId": "cat-14", "name": "Forpigmentering", "duration": 20, "price": 300 },
|
||||
{ "id": "svc-147", "categoryId": "cat-14", "name": "Knække bund ifb. farvebehandling", "duration": 20, "price": 400 },
|
||||
{ "id": "svc-148", "categoryId": "cat-14", "name": "Olaplex i farve", "duration": 10, "price": 230 },
|
||||
{ "id": "svc-149", "categoryId": "cat-14", "name": "Metal DX intens kur redken gloss", "duration": 20, "price": 225 }
|
||||
]
|
||||
}
|
||||
50
PlanTempus.Application/Features/Services/Pages/Index.cshtml
Normal file
50
PlanTempus.Application/Features/Services/Pages/Index.cshtml
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
@page "/services"
|
||||
@using PlanTempus.Application.Features.Services.Components
|
||||
@model PlanTempus.Application.Features.Services.Pages.IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "Services";
|
||||
}
|
||||
|
||||
<swp-services-list-view id="services-list-view">
|
||||
<swp-sticky-header>
|
||||
<swp-header-content>
|
||||
<swp-page-header>
|
||||
<swp-page-title>
|
||||
<h1 localize="services.title">Services</h1>
|
||||
<p localize="services.subtitle">Administrer services og priser</p>
|
||||
</swp-page-title>
|
||||
</swp-page-header>
|
||||
|
||||
<swp-stats-row>
|
||||
@await Component.InvokeAsync("ServiceStatCard", "total-services")
|
||||
@await Component.InvokeAsync("ServiceStatCard", "active-categories")
|
||||
@await Component.InvokeAsync("ServiceStatCard", "average-price")
|
||||
</swp-stats-row>
|
||||
</swp-header-content>
|
||||
|
||||
<swp-tab-bar>
|
||||
<swp-tab class="active" data-tab="services">
|
||||
<i class="ph ph-scissors"></i>
|
||||
<span localize="services.tabs.services">Services</span>
|
||||
</swp-tab>
|
||||
<swp-tab data-tab="categories">
|
||||
<i class="ph ph-folders"></i>
|
||||
<span localize="services.tabs.categories">Kategorier</span>
|
||||
</swp-tab>
|
||||
</swp-tab-bar>
|
||||
</swp-sticky-header>
|
||||
|
||||
<!-- Tab: Services -->
|
||||
<swp-tab-content data-tab="services" class="active">
|
||||
<swp-page-container>
|
||||
@await Component.InvokeAsync("ServiceTable", "all-services")
|
||||
</swp-page-container>
|
||||
</swp-tab-content>
|
||||
|
||||
<!-- Tab: Categories -->
|
||||
<swp-tab-content data-tab="categories">
|
||||
<swp-page-container>
|
||||
@await Component.InvokeAsync("CategoryTable", "all-categories")
|
||||
</swp-page-container>
|
||||
</swp-tab-content>
|
||||
</swp-services-list-view>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace PlanTempus.Application.Features.Services.Pages;
|
||||
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@
|
|||
<link rel="stylesheet" href="~/css/auth.css">
|
||||
<link rel="stylesheet" href="~/css/account.css">
|
||||
<link rel="stylesheet" href="~/css/employees.css">
|
||||
<link rel="stylesheet" href="~/css/services.css">
|
||||
@await RenderSectionAsync("Styles", required: false)
|
||||
</head>
|
||||
<body class="has-demo-banner">
|
||||
|
|
|
|||
236
PlanTempus.Application/wwwroot/css/services.css
Normal file
236
PlanTempus.Application/wwwroot/css/services.css
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
/**
|
||||
* Services List Styles
|
||||
*
|
||||
* Feature-specific styling only.
|
||||
* Reuses: swp-stat-card (stats.css), swp-data-table (components.css),
|
||||
* swp-sticky-header (page.css), swp-row-toggle (employees.css),
|
||||
* swp-btn (components.css)
|
||||
*/
|
||||
|
||||
/* ===========================================
|
||||
SERVICES HEADER (Search + Button)
|
||||
=========================================== */
|
||||
|
||||
swp-services-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--section-gap);
|
||||
}
|
||||
|
||||
swp-search-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
min-width: 280px;
|
||||
|
||||
i {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text);
|
||||
width: 100%;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--color-teal);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
SERVICES LIST TABLE
|
||||
=========================================== */
|
||||
|
||||
swp-card.services-list {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Table columns: Service(1fr) | Varighed(100px) | Pris(100px) | Caret(40px) */
|
||||
swp-card.services-list swp-data-table {
|
||||
grid-template-columns: 1fr 100px 100px 40px;
|
||||
}
|
||||
|
||||
swp-card.services-list swp-data-table-header,
|
||||
swp-card.services-list swp-data-table-row,
|
||||
swp-card.services-list swp-category-row {
|
||||
padding: 0 var(--spacing-10);
|
||||
}
|
||||
|
||||
swp-card.services-list swp-data-table-header swp-data-table-cell {
|
||||
padding-top: var(--spacing-5);
|
||||
padding-bottom: var(--spacing-5);
|
||||
}
|
||||
|
||||
swp-card.services-list swp-data-table-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
swp-card.services-list swp-data-table-cell {
|
||||
padding: var(--spacing-5) 0;
|
||||
|
||||
&:last-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mono font for duration and price columns */
|
||||
swp-card.services-list swp-data-table-row swp-data-table-cell:nth-child(2),
|
||||
swp-card.services-list swp-data-table-row swp-data-table-cell:nth-child(3) {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
CATEGORY ROW
|
||||
=========================================== */
|
||||
|
||||
swp-category-row {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
background: var(--bg-blue-subtle);
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-left: 4px solid var(--color-blue);
|
||||
}
|
||||
|
||||
swp-category-row:hover {
|
||||
background: var(--bg-blue-medium);
|
||||
}
|
||||
|
||||
swp-category-row swp-data-table-cell {
|
||||
padding: var(--spacing-5) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
swp-category-row swp-data-table-cell:first-child {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: var(--font-size-base);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
swp-category-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--color-blue);
|
||||
background: var(--bg-blue-medium);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
swp-category-row:hover swp-category-toggle {
|
||||
background: var(--bg-blue-strong);
|
||||
}
|
||||
|
||||
swp-category-toggle i {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
swp-category-row[data-expanded="false"] swp-category-toggle i {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.category-name {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.category-count {
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: var(--font-weight-normal);
|
||||
font-size: var(--font-size-xs);
|
||||
margin-left: var(--spacing-2);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
SERVICE ROW (indented under category)
|
||||
=========================================== */
|
||||
|
||||
swp-card.services-list swp-data-table-row swp-data-table-cell:first-child {
|
||||
padding-left: 56px;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
SERVICE ROW HOVER
|
||||
=========================================== */
|
||||
|
||||
swp-card.services-list swp-data-table-row:hover {
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
|
||||
swp-card.services-list swp-data-table-row:hover swp-row-toggle {
|
||||
color: var(--color-teal);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
CATEGORIES LIST TABLE
|
||||
=========================================== */
|
||||
|
||||
swp-card.categories-list {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Table columns: Category(1fr) | ServiceCount(120px) | Caret(40px) */
|
||||
swp-card.categories-list swp-data-table {
|
||||
grid-template-columns: 1fr 120px 40px;
|
||||
}
|
||||
|
||||
swp-card.categories-list swp-data-table-header,
|
||||
swp-card.categories-list swp-data-table-row {
|
||||
padding: 0 var(--spacing-10);
|
||||
}
|
||||
|
||||
swp-card.categories-list swp-data-table-header swp-data-table-cell {
|
||||
padding-top: var(--spacing-5);
|
||||
padding-bottom: var(--spacing-5);
|
||||
}
|
||||
|
||||
swp-card.categories-list swp-data-table-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
swp-card.categories-list swp-data-table-cell {
|
||||
padding: var(--spacing-5) 0;
|
||||
|
||||
&:last-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mono font for service count */
|
||||
swp-card.categories-list swp-data-table-row swp-data-table-cell:nth-child(2) {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
swp-card.categories-list swp-data-table-row:hover {
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
|
||||
swp-card.categories-list swp-data-table-row:hover swp-row-toggle {
|
||||
color: var(--color-teal);
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import { LockScreenController } from './modules/lockscreen';
|
|||
import { CashController } from './modules/cash';
|
||||
import { EmployeesController } from './modules/employees';
|
||||
import { ControlsController } from './modules/controls';
|
||||
import { ServicesController } from './modules/services';
|
||||
|
||||
/**
|
||||
* Main application class
|
||||
|
|
@ -25,6 +26,7 @@ export class App {
|
|||
readonly cash: CashController;
|
||||
readonly employees: EmployeesController;
|
||||
readonly controls: ControlsController;
|
||||
readonly services: ServicesController;
|
||||
|
||||
constructor() {
|
||||
// Initialize controllers
|
||||
|
|
@ -36,6 +38,7 @@ export class App {
|
|||
this.cash = new CashController();
|
||||
this.employees = new EmployeesController();
|
||||
this.controls = new ControlsController();
|
||||
this.services = new ServicesController();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
111
PlanTempus.Application/wwwroot/ts/modules/services.ts
Normal file
111
PlanTempus.Application/wwwroot/ts/modules/services.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* Services Controller
|
||||
* Handles category collapse/expand animations
|
||||
*/
|
||||
|
||||
export class ServicesController {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
this.setupCategoryToggle();
|
||||
}
|
||||
|
||||
private setupCategoryToggle(): void {
|
||||
document.addEventListener('click', (e) => {
|
||||
const categoryRow = (e.target as HTMLElement).closest('swp-category-row');
|
||||
if (!categoryRow) return;
|
||||
|
||||
const isExpanded = categoryRow.getAttribute('data-expanded') !== 'false';
|
||||
const categoryId = categoryRow.getAttribute('data-category');
|
||||
|
||||
// Find all service rows belonging to this category
|
||||
const serviceRows = this.getServiceRowsForCategory(categoryRow);
|
||||
|
||||
if (isExpanded) {
|
||||
// Collapse - set attribute immediately so chevron animates with rows
|
||||
categoryRow.setAttribute('data-expanded', 'false');
|
||||
this.collapseRows(serviceRows);
|
||||
} else {
|
||||
// Expand - set attribute immediately so chevron animates with rows
|
||||
categoryRow.setAttribute('data-expanded', 'true');
|
||||
this.expandRows(serviceRows);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getServiceRowsForCategory(categoryRow: Element): HTMLElement[] {
|
||||
const rows: HTMLElement[] = [];
|
||||
let sibling = categoryRow.nextElementSibling;
|
||||
|
||||
while (sibling && sibling.tagName.toLowerCase() === 'swp-data-table-row') {
|
||||
rows.push(sibling as HTMLElement);
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private collapseRows(rows: HTMLElement[]): void {
|
||||
if (rows.length === 0) return;
|
||||
|
||||
// Animate each row
|
||||
rows.forEach((row) => {
|
||||
const height = row.offsetHeight;
|
||||
row.style.height = `${height}px`;
|
||||
row.style.overflow = 'hidden';
|
||||
|
||||
// Force reflow
|
||||
row.offsetHeight;
|
||||
|
||||
row.style.transition = 'height 0.2s ease, opacity 0.2s ease';
|
||||
row.style.height = '0';
|
||||
row.style.opacity = '0';
|
||||
});
|
||||
|
||||
// After animation completes
|
||||
setTimeout(() => {
|
||||
rows.forEach(row => {
|
||||
row.style.display = 'none';
|
||||
row.style.height = '';
|
||||
row.style.opacity = '';
|
||||
row.style.overflow = '';
|
||||
row.style.transition = '';
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
|
||||
private expandRows(rows: HTMLElement[]): void {
|
||||
rows.forEach((row, index) => {
|
||||
// First make visible but with 0 height
|
||||
row.style.display = 'grid';
|
||||
row.style.height = '0';
|
||||
row.style.opacity = '0';
|
||||
row.style.overflow = 'hidden';
|
||||
|
||||
// Measure natural height
|
||||
row.style.height = 'auto';
|
||||
const naturalHeight = row.offsetHeight;
|
||||
row.style.height = '0';
|
||||
|
||||
// Force reflow
|
||||
row.offsetHeight;
|
||||
|
||||
// Animate to natural height
|
||||
row.style.transition = 'height 0.2s ease, opacity 0.2s ease';
|
||||
row.style.height = `${naturalHeight}px`;
|
||||
row.style.opacity = '1';
|
||||
});
|
||||
|
||||
// Cleanup after animation
|
||||
setTimeout(() => {
|
||||
rows.forEach(row => {
|
||||
row.style.height = '';
|
||||
row.style.opacity = '';
|
||||
row.style.overflow = '';
|
||||
row.style.transition = '';
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue