diff --git a/PlanTempus.Application/Features/Localization/Translations/da.json b/PlanTempus.Application/Features/Localization/Translations/da.json index d3efb9f..657f464 100644 --- a/PlanTempus.Application/Features/Localization/Translations/da.json +++ b/PlanTempus.Application/Features/Localization/Translations/da.json @@ -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", diff --git a/PlanTempus.Application/Features/Localization/Translations/en.json b/PlanTempus.Application/Features/Localization/Translations/en.json index dec65aa..ac05b22 100644 --- a/PlanTempus.Application/Features/Localization/Translations/en.json +++ b/PlanTempus.Application/Features/Localization/Translations/en.json @@ -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", diff --git a/PlanTempus.Application/Features/Menu/Services/MockMenuService.cs b/PlanTempus.Application/Features/Menu/Services/MockMenuService.cs index 45791b7..edce578 100644 --- a/PlanTempus.Application/Features/Menu/Services/MockMenuService.cs +++ b/PlanTempus.Application/Features/Menu/Services/MockMenuService.cs @@ -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 } } }, diff --git a/PlanTempus.Application/Features/Services/Components/CategoryTable/CategoryTableViewComponent.cs b/PlanTempus.Application/Features/Services/Components/CategoryTable/CategoryTableViewComponent.cs new file mode 100644 index 0000000..2720403 --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/CategoryTable/CategoryTableViewComponent.cs @@ -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(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 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; } +} diff --git a/PlanTempus.Application/Features/Services/Components/CategoryTable/Default.cshtml b/PlanTempus.Application/Features/Services/Components/CategoryTable/Default.cshtml new file mode 100644 index 0000000..57e21e6 --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/CategoryTable/Default.cshtml @@ -0,0 +1,31 @@ +@model PlanTempus.Application.Features.Services.Components.CategoryTableViewModel + + +
+ + + @Model.CreateButtonText + +
+ + + + + @Model.ColumnCategory + @Model.ColumnServiceCount + + + @foreach (var category in Model.Categories) + { + + @category.Name + @category.ServiceCount + + + + + + + } + + diff --git a/PlanTempus.Application/Features/Services/Components/ServiceCategoryGroup/Default.cshtml b/PlanTempus.Application/Features/Services/Components/ServiceCategoryGroup/Default.cshtml new file mode 100644 index 0000000..0d9465a --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/ServiceCategoryGroup/Default.cshtml @@ -0,0 +1,19 @@ +@model PlanTempus.Application.Features.Services.Components.ServiceCategoryViewModel + + + + + + + @Model.Name + (@Model.Services.Count) + + + + + + +@foreach (var service in Model.Services) +{ + @await Component.InvokeAsync("ServiceRow", service) +} diff --git a/PlanTempus.Application/Features/Services/Components/ServiceCategoryGroup/ServiceCategoryGroupViewComponent.cs b/PlanTempus.Application/Features/Services/Components/ServiceCategoryGroup/ServiceCategoryGroupViewComponent.cs new file mode 100644 index 0000000..3fb2908 --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/ServiceCategoryGroup/ServiceCategoryGroupViewComponent.cs @@ -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); + } +} diff --git a/PlanTempus.Application/Features/Services/Components/ServiceRow/Default.cshtml b/PlanTempus.Application/Features/Services/Components/ServiceRow/Default.cshtml new file mode 100644 index 0000000..3daf96b --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/ServiceRow/Default.cshtml @@ -0,0 +1,12 @@ +@model PlanTempus.Application.Features.Services.Components.ServiceItemViewModel + + + @Model.Name + @Model.Duration min + @Model.Price.ToString("N0") kr + + + + + + diff --git a/PlanTempus.Application/Features/Services/Components/ServiceRow/ServiceRowViewComponent.cs b/PlanTempus.Application/Features/Services/Components/ServiceRow/ServiceRowViewComponent.cs new file mode 100644 index 0000000..1eca20e --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/ServiceRow/ServiceRowViewComponent.cs @@ -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); + } +} diff --git a/PlanTempus.Application/Features/Services/Components/ServiceStatCard/Default.cshtml b/PlanTempus.Application/Features/Services/Components/ServiceStatCard/Default.cshtml new file mode 100644 index 0000000..1bc1923 --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/ServiceStatCard/Default.cshtml @@ -0,0 +1,6 @@ +@model PlanTempus.Application.Features.Services.Components.ServiceStatCardViewModel + + + @Model.Value + @Model.Label + diff --git a/PlanTempus.Application/Features/Services/Components/ServiceStatCard/ServiceStatCardViewComponent.cs b/PlanTempus.Application/Features/Services/Components/ServiceStatCard/ServiceStatCardViewComponent.cs new file mode 100644 index 0000000..a9dd5a3 --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/ServiceStatCard/ServiceStatCardViewComponent.cs @@ -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 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 AllKeys => Cards.Keys; +} diff --git a/PlanTempus.Application/Features/Services/Components/ServiceTable/Default.cshtml b/PlanTempus.Application/Features/Services/Components/ServiceTable/Default.cshtml new file mode 100644 index 0000000..ea20b9b --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/ServiceTable/Default.cshtml @@ -0,0 +1,27 @@ +@model PlanTempus.Application.Features.Services.Components.ServiceTableViewModel + + + + + + + + + @Model.CreateButtonText + + + + + + + @Model.ColumnService + @Model.ColumnDuration + @Model.ColumnPrice + + + @foreach (var category in Model.Categories) + { + @await Component.InvokeAsync("ServiceCategoryGroup", category) + } + + diff --git a/PlanTempus.Application/Features/Services/Components/ServiceTable/ServiceTableViewComponent.cs b/PlanTempus.Application/Features/Services/Components/ServiceTable/ServiceTableViewComponent.cs new file mode 100644 index 0000000..95fd115 --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/ServiceTable/ServiceTableViewComponent.cs @@ -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(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 Categories { get; init; } +} + +public class ServiceCategoryViewModel +{ + public required string Id { get; init; } + public required string Name { get; init; } + public required IReadOnlyList 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 Categories { get; set; } = new(); + public List 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; } +} diff --git a/PlanTempus.Application/Features/Services/Data/servicesMock.json b/PlanTempus.Application/Features/Services/Data/servicesMock.json new file mode 100644 index 0000000..8bfab16 --- /dev/null +++ b/PlanTempus.Application/Features/Services/Data/servicesMock.json @@ -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 } + ] +} diff --git a/PlanTempus.Application/Features/Services/Pages/Index.cshtml b/PlanTempus.Application/Features/Services/Pages/Index.cshtml new file mode 100644 index 0000000..c769c80 --- /dev/null +++ b/PlanTempus.Application/Features/Services/Pages/Index.cshtml @@ -0,0 +1,50 @@ +@page "/services" +@using PlanTempus.Application.Features.Services.Components +@model PlanTempus.Application.Features.Services.Pages.IndexModel +@{ + ViewData["Title"] = "Services"; +} + + + + + + +

Services

+

Administrer services og priser

+
+
+ + + @await Component.InvokeAsync("ServiceStatCard", "total-services") + @await Component.InvokeAsync("ServiceStatCard", "active-categories") + @await Component.InvokeAsync("ServiceStatCard", "average-price") + +
+ + + + + Services + + + + Kategorier + + +
+ + + + + @await Component.InvokeAsync("ServiceTable", "all-services") + + + + + + + @await Component.InvokeAsync("CategoryTable", "all-categories") + + +
diff --git a/PlanTempus.Application/Features/Services/Pages/Index.cshtml.cs b/PlanTempus.Application/Features/Services/Pages/Index.cshtml.cs new file mode 100644 index 0000000..733a323 --- /dev/null +++ b/PlanTempus.Application/Features/Services/Pages/Index.cshtml.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace PlanTempus.Application.Features.Services.Pages; + +public class IndexModel : PageModel +{ + public void OnGet() + { + } +} diff --git a/PlanTempus.Application/Features/_Shared/Pages/_Layout.cshtml b/PlanTempus.Application/Features/_Shared/Pages/_Layout.cshtml index d694710..137d7d6 100644 --- a/PlanTempus.Application/Features/_Shared/Pages/_Layout.cshtml +++ b/PlanTempus.Application/Features/_Shared/Pages/_Layout.cshtml @@ -32,6 +32,7 @@ + @await RenderSectionAsync("Styles", required: false) diff --git a/PlanTempus.Application/wwwroot/css/services.css b/PlanTempus.Application/wwwroot/css/services.css new file mode 100644 index 0000000..5f8f472 --- /dev/null +++ b/PlanTempus.Application/wwwroot/css/services.css @@ -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); +} diff --git a/PlanTempus.Application/wwwroot/ts/app.ts b/PlanTempus.Application/wwwroot/ts/app.ts index d07676f..0c66ab5 100644 --- a/PlanTempus.Application/wwwroot/ts/app.ts +++ b/PlanTempus.Application/wwwroot/ts/app.ts @@ -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(); } } diff --git a/PlanTempus.Application/wwwroot/ts/modules/services.ts b/PlanTempus.Application/wwwroot/ts/modules/services.ts new file mode 100644 index 0000000..2ef5e6c --- /dev/null +++ b/PlanTempus.Application/wwwroot/ts/modules/services.ts @@ -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); + } +}