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

@ -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",

View file

@ -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",

View file

@ -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
}
}
},

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; }
}

View 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 }
]
}

View 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>

View file

@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace PlanTempus.Application.Features.Services.Pages;
public class IndexModel : PageModel
{
public void OnGet()
{
}
}

View file

@ -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">