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",
|
"suppliers": "Leverandører",
|
||||||
"customers": "Kunder",
|
"customers": "Kunder",
|
||||||
"employees": "Medarbejdere",
|
"employees": "Medarbejdere",
|
||||||
|
"services": "Services",
|
||||||
"reports": "Statistik & Rapporter",
|
"reports": "Statistik & Rapporter",
|
||||||
"settings": "Indstillinger",
|
"settings": "Indstillinger",
|
||||||
"account": "Abonnement & Konto"
|
"account": "Abonnement & Konto"
|
||||||
|
|
@ -219,6 +220,29 @@
|
||||||
"overdue": "Forfalden"
|
"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": {
|
"employees": {
|
||||||
"title": "Medarbejdere",
|
"title": "Medarbejdere",
|
||||||
"subtitle": "Administrer brugere, roller og rettigheder",
|
"subtitle": "Administrer brugere, roller og rettigheder",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"suppliers": "Suppliers",
|
"suppliers": "Suppliers",
|
||||||
"customers": "Customers",
|
"customers": "Customers",
|
||||||
"employees": "Employees",
|
"employees": "Employees",
|
||||||
|
"services": "Services",
|
||||||
"reports": "Statistics & Reports",
|
"reports": "Statistics & Reports",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"account": "Subscription & Account"
|
"account": "Subscription & Account"
|
||||||
|
|
@ -219,6 +220,29 @@
|
||||||
"overdue": "Overdue"
|
"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": {
|
"employees": {
|
||||||
"title": "Employees",
|
"title": "Employees",
|
||||||
"subtitle": "Manage users, roles and permissions",
|
"subtitle": "Manage users, roles and permissions",
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,15 @@ public class MockMenuService : IMenuService
|
||||||
Url = "/medarbejdere",
|
Url = "/medarbejdere",
|
||||||
MinimumRole = UserRole.Manager,
|
MinimumRole = UserRole.Manager,
|
||||||
SortOrder = 4
|
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/auth.css">
|
||||||
<link rel="stylesheet" href="~/css/account.css">
|
<link rel="stylesheet" href="~/css/account.css">
|
||||||
<link rel="stylesheet" href="~/css/employees.css">
|
<link rel="stylesheet" href="~/css/employees.css">
|
||||||
|
<link rel="stylesheet" href="~/css/services.css">
|
||||||
@await RenderSectionAsync("Styles", required: false)
|
@await RenderSectionAsync("Styles", required: false)
|
||||||
</head>
|
</head>
|
||||||
<body class="has-demo-banner">
|
<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 { CashController } from './modules/cash';
|
||||||
import { EmployeesController } from './modules/employees';
|
import { EmployeesController } from './modules/employees';
|
||||||
import { ControlsController } from './modules/controls';
|
import { ControlsController } from './modules/controls';
|
||||||
|
import { ServicesController } from './modules/services';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main application class
|
* Main application class
|
||||||
|
|
@ -25,6 +26,7 @@ export class App {
|
||||||
readonly cash: CashController;
|
readonly cash: CashController;
|
||||||
readonly employees: EmployeesController;
|
readonly employees: EmployeesController;
|
||||||
readonly controls: ControlsController;
|
readonly controls: ControlsController;
|
||||||
|
readonly services: ServicesController;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Initialize controllers
|
// Initialize controllers
|
||||||
|
|
@ -36,6 +38,7 @@ export class App {
|
||||||
this.cash = new CashController();
|
this.cash = new CashController();
|
||||||
this.employees = new EmployeesController();
|
this.employees = new EmployeesController();
|
||||||
this.controls = new ControlsController();
|
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